Skip to content

Commit 5437670

Browse files
author
isaac
committed
WIP
1 parent a45ea21 commit 5437670

10 files changed

Lines changed: 974 additions & 830 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,4 @@ xcode-files
8383
/buildbox/*
8484
**/node_modules/
8585
tools/sim-watcher/dist/
86+
.mcp.json

‎submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,23 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
272272
/// non-pending-edit) InstantPage — the only rendering whose checkbox paths are safe to
273273
/// edit — AND the message is editable.
274274
private func checkboxesInteractive(item: ChatMessageBubbleContentItem, resolved: ResolvedRichDataContent) -> Bool {
275-
// Only the `.original` key is eligible; `.translated` and `.pendingEdit` are inert.
276-
guard case .original = resolved.key else {
275+
// `.original` (server state) and `.pendingEdit` (an in-flight edit) are both eligible —
276+
// keeping checkboxes live during the pending round-trip lets the user toggle several boxes
277+
// in a row. `.translated` is inert, as is the translation-pending fallback (which reports an
278+
// `.original` key over the genuine original page but with `isTranslating == true`).
279+
switch resolved.key {
280+
case .original, .pendingEdit:
281+
break
282+
case .translated:
277283
return false
278284
}
279-
// The primary page is `attribute.instantPage` (class identity). The show-more
280-
// (`fullInstantPage`) rendering also carries an `.original` key but a different page
281-
// object, so an identity check excludes it. `originalAttribute` is Optional.
285+
if resolved.isTranslating {
286+
return false
287+
}
288+
// The primary page is the resolved attribute's `instantPage` (class identity). The show-more
289+
// (`fullInstantPage`) rendering carries the same key but a different page object, so an
290+
// identity check excludes it. `originalAttribute` is Optional (it is the pending edit's
291+
// attribute in the `.pendingEdit` case).
282292
guard let attribute = resolved.originalAttribute, resolved.instantPage === attribute.instantPage else {
283293
return false
284294
}
@@ -761,7 +771,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
761771
dateText: dateText,
762772
type: statusType,
763773
layoutInput: dateLayoutInput,
764-
constrainedSize: CGSize(width: suggestedBoundingWidth, height: .greatestFiniteMagnitude),
774+
constrainedSize: CGSize(width: boundingSize.width, height: .greatestFiniteMagnitude),
765775
availableReactions: item.associatedData.availableReactions,
766776
savedMessageTags: item.associatedData.savedMessageTags,
767777
// Empty the status node's own reactions exactly when they are externalized

‎submodules/TelegramUI/Components/RichTextAttachmentScreen/Sources/RichTextAttachmentScreen.swift‎

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,13 @@ private final class RichTextSendButtonComponent: Component {
214214
}
215215

216216
public class RichTextAttachmentScreen: ViewControllerComponentContainer, AttachmentContainable {
217+
public typealias Document = RichTextEditorCore::Document
218+
219+
public enum Mode {
220+
case standalone(savedDraft: Document?, media: [String: Media], emojiFiles: [Int64: TelegramMediaFile])
221+
case edit(initialDocument: Document?, media: [String: Media], emojiFiles: [Int64: TelegramMediaFile])
222+
}
223+
217224
public enum RichTextAttachment {
218225
case image(ImageMediaReference)
219226
case file(FileMediaReference)
@@ -247,19 +254,15 @@ public class RichTextAttachmentScreen: ViewControllerComponentContainer, Attachm
247254

248255
public convenience init(
249256
context: AccountContext,
250-
initialContents: Document? = nil,
251-
initialMedia: [String: Media] = [:],
252-
initialEmojiFiles: [Int64: TelegramMediaFile] = [:],
257+
mode: Mode,
253258
sendMessage: @escaping (Document, [String: Media], [Int64: TelegramMediaFile]) -> Void,
254259
syncContent: ((Document, [String: Media], [Int64: TelegramMediaFile]) -> Void)? = nil,
255260
presentAttachmentMenu: ((_ photoVideoOnly: Bool, @escaping (RichTextAttachmentScreen.RichTextAttachment) -> Void) -> Void)?,
256261
presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?
257262
) {
258263
self.init(
259264
context: context,
260-
initialContents: initialContents,
261-
initialMedia: initialMedia,
262-
initialEmojiFiles: initialEmojiFiles,
265+
mode: mode,
263266
sendMessage: { document, media, emojiFiles, _ in
264267
sendMessage(document, media, emojiFiles)
265268
},
@@ -271,9 +274,7 @@ public class RichTextAttachmentScreen: ViewControllerComponentContainer, Attachm
271274

272275
public init(
273276
context: AccountContext,
274-
initialContents: Document? = nil,
275-
initialMedia: [String: Media] = [:],
276-
initialEmojiFiles: [Int64: TelegramMediaFile] = [:],
277+
mode: Mode,
277278
sendMessage: @escaping (Document, [String: Media], [Int64: TelegramMediaFile], Bool) -> Void,
278279
syncContent: ((Document, [String: Media], [Int64: TelegramMediaFile]) -> Void)? = nil,
279280
presentAttachmentMenu: ((_ photoVideoOnly: Bool, @escaping (RichTextAttachmentScreen.RichTextAttachment) -> Void) -> Void)?,
@@ -287,9 +288,7 @@ public class RichTextAttachmentScreen: ViewControllerComponentContainer, Attachm
287288

288289
super.init(context: context, component: RichTextAttachmentScreenComponent(
289290
context: context,
290-
initialContents: initialContents,
291-
initialMedia: initialMedia,
292-
initialEmojiFiles: initialEmojiFiles,
291+
mode: mode,
293292
overNavigationContainer: overNavigationContainer,
294293
presentAttachmentMenu: presentAttachmentMenu,
295294
presentFormulaEditor: presentFormulaEditor
@@ -318,6 +317,13 @@ public class RichTextAttachmentScreen: ViewControllerComponentContainer, Attachm
318317
fatalError("init(coder:) has not been implemented")
319318
}
320319

320+
public func resetForReuse() {
321+
guard let syncContent = self.syncContent, let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
322+
return
323+
}
324+
syncContent(componentView.currentDocument, componentView.currentMedia, componentView.currentEmojiFiles)
325+
}
326+
321327
fileprivate func donePressed() {
322328
guard let componentView = self.node.hostView.componentView as? RichTextAttachmentScreenComponent.View else {
323329
return
@@ -373,18 +379,14 @@ final class RichTextAttachmentScreenComponent: Component {
373379
// Held for the next step: the RichTextEditor demo will read context and add
374380
// its editor view into the View's content container.
375381
let context: AccountContext
376-
let initialContents: Document?
377-
let initialMedia: [String: Media]
378-
let initialEmojiFiles: [Int64: TelegramMediaFile]
382+
let mode: RichTextAttachmentScreen.Mode
379383
let overNavigationContainer: UIView
380384
let presentAttachmentMenu: ((_ photoVideoOnly: Bool, @escaping (RichTextAttachmentScreen.RichTextAttachment) -> Void) -> Void)?
381385
let presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?
382386

383-
init(context: AccountContext, initialContents: Document?, initialMedia: [String: Media], initialEmojiFiles: [Int64: TelegramMediaFile], overNavigationContainer: UIView, presentAttachmentMenu: ((_ photoVideoOnly: Bool, @escaping (RichTextAttachmentScreen.RichTextAttachment) -> Void) -> Void)?, presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?) {
387+
init(context: AccountContext, mode: RichTextAttachmentScreen.Mode, overNavigationContainer: UIView, presentAttachmentMenu: ((_ photoVideoOnly: Bool, @escaping (RichTextAttachmentScreen.RichTextAttachment) -> Void) -> Void)?, presentFormulaEditor: ((_ initialValue: String?, _ completion: @escaping (String) -> Void) -> Void)?) {
384388
self.context = context
385-
self.initialContents = initialContents
386-
self.initialMedia = initialMedia
387-
self.initialEmojiFiles = initialEmojiFiles
389+
self.mode = mode
388390
self.overNavigationContainer = overNavigationContainer
389391
self.presentAttachmentMenu = presentAttachmentMenu
390392
self.presentFormulaEditor = presentFormulaEditor
@@ -733,10 +735,25 @@ final class RichTextAttachmentScreenComponent: Component {
733735
editor.disablesInteractiveKeyboardGestureRecognizer = true
734736
// Seed the editor with the caller-supplied initial content (e.g. the chat composer's
735737
// current document when expanding); an empty document when none is provided.
736-
editor.document = component.initialContents ?? Document()
738+
739+
var initialContents: Document?
740+
var initialMedia: [String: Media] = [:]
741+
var initialEmojiFiles: [Int64: TelegramMediaFile] = [:]
742+
switch component.mode {
743+
case let .edit(documentValue, mediaValue, emojiFilesValue):
744+
initialContents = documentValue
745+
initialMedia = mediaValue
746+
initialEmojiFiles = emojiFilesValue
747+
case let .standalone(documentValue, mediaValue, emojiFilesValue):
748+
initialContents = documentValue
749+
initialMedia = mediaValue
750+
initialEmojiFiles = emojiFilesValue
751+
}
752+
753+
editor.document = initialContents ?? Document()
737754
// Seed the picked-media store alongside the document (before the media-view provider runs)
738755
// so any media referenced by the initial document resolves on first layout.
739-
self.attachedMedia = component.initialMedia
756+
self.attachedMedia = initialMedia
740757

741758
let emojiKeyboard = RichTextEmojiKeyboardController(context: component.context, editor: editor, requestLayout: { [weak self] in
742759
guard let self, !self.isUpdating else { return }
@@ -746,7 +763,7 @@ final class RichTextAttachmentScreenComponent: Component {
746763
// Seed the keyboard's file store with the files of any custom emoji the initial document
747764
// references (the `Document` carries only fileIds) — before the editor's first layout, so a
748765
// custom emoji carried in from the chat composer renders, and its file survives back out.
749-
emojiKeyboard.seedEmojiFiles(component.initialEmojiFiles)
766+
emojiKeyboard.seedEmojiFiles(initialEmojiFiles)
750767

751768
editor.registerEmojiViewProvider { [weak self] id, size in
752769
return self?.emojiKeyboard?.customEmojiView(forId: id, size: size)
@@ -911,7 +928,7 @@ final class RichTextAttachmentScreenComponent: Component {
911928
transition.setFrame(view: rightNavActionsBarView, frame: rightNavActionsBarFrame)
912929
}
913930

914-
if component.initialContents == nil {
931+
if case .standalone = component.mode {
915932
let titleSize = self.title.update(
916933
transition: .immediate,
917934
component: AnyComponent(
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "collage1.pdf",
5+
"idiom" : "universal"
6+
}
7+
],
8+
"info" : {
9+
"author" : "xcode",
10+
"version" : 1
11+
}
12+
}
Binary file not shown.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "collage2.pdf",
5+
"idiom" : "universal"
6+
}
7+
],
8+
"info" : {
9+
"author" : "xcode",
10+
"version" : 1
11+
}
12+
}
Binary file not shown.

‎submodules/TelegramUI/Sources/ChatController.swift‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,17 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
358358
var selectPollOptionFeedback: HapticFeedback?
359359

360360
var updateMessageTodoDisposables: DisposableDict<MessageId>?
361-
361+
362+
// Rich-message checkbox tap-to-toggle. A running toggled page per message accumulates rapid
363+
// taps (so an earlier toggle is never dropped by a later one computed against stale state), and
364+
// a short debounce coalesces a burst into a single message edit. The completion observer clears
365+
// the accumulator once the message's pending edit settles, so a later toggle re-bases from fresh
366+
// server state (picking up composer/remote edits).
367+
private var richTextCheckboxAccumulators: [MessageId: InstantPage] = [:]
368+
private var richTextCheckboxDebounceTimers: [MessageId: SwiftSignalKit.Timer] = [:]
369+
private var richTextCheckboxCompletionObserver: Disposable?
370+
private let richTextCheckboxDebounceInterval: Double = 0.35
371+
362372
var resolveUrlDisposable: MetaDisposable?
363373

364374
var contextQueryStates: [ChatPresentationInputQueryKind: (ChatPresentationInputQuery, Disposable)] = [:]
@@ -5785,8 +5795,14 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
57855795
guard let attribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute else {
57865796
return
57875797
}
5788-
let newPage = attribute.instantPage.togglingCheckbox(at: path, to: value)
5789-
let newAttribute = RichTextMessageAttribute(instantPage: newPage, fullInstantPage: attribute.fullInstantPage)
5798+
// Start the completion observer before registering an accumulator, so its initial
5799+
// (empty) emission can't clear what we are about to add.
5800+
self.setupRichTextCheckboxCompletionObserverIfNeeded()
5801+
// Base off the running accumulator when mid-burst so a rapid earlier toggle is never
5802+
// dropped by a later one; otherwise start from fresh server state.
5803+
let basePage = self.richTextCheckboxAccumulators[messageId] ?? attribute.instantPage
5804+
let newPage = basePage.togglingCheckbox(at: path, to: value)
5805+
self.richTextCheckboxAccumulators[messageId] = newPage
57905806

57915807
if self.selectPollOptionFeedback == nil {
57925808
self.selectPollOptionFeedback = HapticFeedback()
@@ -5797,7 +5813,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
57975813
self.selectPollOptionFeedback?.impact(.medium)
57985814
}
57995815

5800-
self.context.account.pendingUpdateMessageManager.add(messageId: messageId, text: "", media: .keep, entities: nil, richText: newAttribute, inlineStickers: [:])
5816+
// Debounce: coalesce a rapid burst of taps into one edit once tapping quiesces.
5817+
self.richTextCheckboxDebounceTimers[messageId]?.invalidate()
5818+
let timer = SwiftSignalKit.Timer(timeout: self.richTextCheckboxDebounceInterval, repeat: false, completion: { [weak self] in
5819+
self?.flushRichTextCheckboxEdit(messageId: messageId)
5820+
}, queue: .mainQueue())
5821+
self.richTextCheckboxDebounceTimers[messageId] = timer
5822+
timer.start()
58015823
}, openStarsPurchase: { [weak self] amount in
58025824
self?.interfaceInteraction?.openStarsPurchase(amount)
58035825
}, openRankInfo: { [weak self] peer, role, rank in
@@ -6893,11 +6915,62 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
68936915
fatalError("init(coder:) has not been implemented")
68946916
}
68956917

6918+
private func flushRichTextCheckboxEdit(messageId: MessageId) {
6919+
self.richTextCheckboxDebounceTimers.removeValue(forKey: messageId)?.invalidate()
6920+
guard let page = self.richTextCheckboxAccumulators[messageId] else {
6921+
return
6922+
}
6923+
guard let message = self.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId)?._asMessage() else {
6924+
// No `add` will fire (so the completion observer won't run) — drop the accumulator here,
6925+
// otherwise it lingers stale and a later toggle could revert an intervening edit.
6926+
self.richTextCheckboxAccumulators.removeValue(forKey: messageId)
6927+
return
6928+
}
6929+
guard canEditMessage(context: self.context, limitsConfiguration: self.context.currentLimitsConfiguration.with { EngineConfiguration.Limits($0) }, message: message) else {
6930+
self.richTextCheckboxAccumulators.removeValue(forKey: messageId)
6931+
return
6932+
}
6933+
guard let attribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute else {
6934+
self.richTextCheckboxAccumulators.removeValue(forKey: messageId)
6935+
return
6936+
}
6937+
// Preserve the message's current layout attributes so a checkbox toggle changes nothing but
6938+
// the checkbox (matching the composer's rich-edit; these are typically absent on a rich
6939+
// message, so this is defensive).
6940+
let invertMediaAttribute = message.attributes.first(where: { $0 is InvertMediaMessageAttribute }) as? InvertMediaMessageAttribute
6941+
let newAttribute = RichTextMessageAttribute(instantPage: page, fullInstantPage: attribute.fullInstantPage)
6942+
self.context.account.pendingUpdateMessageManager.add(messageId: messageId, text: "", media: .keep, entities: nil, richText: newAttribute, inlineStickers: [:], webpagePreviewAttribute: message.webpagePreviewAttribute, invertMediaAttribute: invertMediaAttribute, disableUrlPreview: false)
6943+
}
6944+
6945+
private func setupRichTextCheckboxCompletionObserverIfNeeded() {
6946+
if self.richTextCheckboxCompletionObserver != nil {
6947+
return
6948+
}
6949+
self.richTextCheckboxCompletionObserver = (self.context.account.pendingUpdateMessageManager.updatingMessageMedia
6950+
|> deliverOnMainQueue).startStrict(next: { [weak self] pendingMap in
6951+
guard let self else {
6952+
return
6953+
}
6954+
for messageId in Array(self.richTextCheckboxAccumulators.keys) {
6955+
// Once the message is neither pending nor has a queued (debounced) toggle, the server
6956+
// state is authoritative — drop the accumulator so the next toggle re-bases from it.
6957+
if pendingMap[messageId] == nil && self.richTextCheckboxDebounceTimers[messageId] == nil {
6958+
self.richTextCheckboxAccumulators.removeValue(forKey: messageId)
6959+
}
6960+
}
6961+
})
6962+
}
6963+
68966964
deinit {
68976965
let _ = ChatControllerCount.modify { value in
68986966
return value - 1
68996967
}
6900-
6968+
6969+
self.richTextCheckboxCompletionObserver?.dispose()
6970+
for (_, timer) in self.richTextCheckboxDebounceTimers {
6971+
timer.invalidate()
6972+
}
6973+
69016974
self.historyStateDisposable?.dispose()
69026975
self.messageIndexDisposable.dispose()
69036976
self.navigationActionDisposable.dispose()

‎submodules/TelegramUI/Sources/ChatControllerNode.swift‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4612,9 +4612,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate {
46124612
let (seedDocument, seedMedia, seedEmojiFiles) = documentMediaAndEmoji(fromChatInputContent: textInputPanelNode.inputTextState.content)
46134613
let editorScreen = RichTextAttachmentScreen(
46144614
context: self.context,
4615-
initialContents: seedDocument,
4616-
initialMedia: seedMedia,
4617-
initialEmojiFiles: seedEmojiFiles,
4615+
mode: .edit(initialDocument: seedDocument, media: seedMedia, emojiFiles: seedEmojiFiles),
46184616
sendMessage: { [weak self] document, media, emojiFiles, sendWithoutFormatting in
46194617
guard let self else {
46204618
return

0 commit comments

Comments
 (0)