Skip to content

Commit 6be9999

Browse files
authored
Keep Article drafts after relaunch (#136)
* Keep Article drafts after relaunch Rich text drafts left in the composer after closing the Article editor lived only in process memory, so quitting Telegram discarded them while regular drafts survived. Store them in the item cache under richTextComposerDrafts, keyed by peer and thread like iOS, restore them when the chat opens, and remove them once the draft is sent or cleared. * Rework Article draft persistence after review Store drafts in an app-specific item cache collection and keep the entry type in its own file, so the test harnesses that paste ChatRichTextDraftStore.swift compile again. Keep the stored copy when the rich editor takes the draft, load it only after the stored text draft is applied, reload it when the thread changes in place, and drop reads that return for another thread. Clear the stored copy when a message is sent, and store inline editor edits half a second after the last change and when the chat closes. * Keep Article drafts apart when the topic changes in place Finish a pending inline save for the old topic, fill the inline editor with the new topic's draft even when it has focus, write the draft only when it changed, and keep the business link editor out of the saved draft slot. * Keep unshown Article drafts and save the editor window on quit Reload the inline draft only when the topic or chat really changes, so typing in a topic no longer reseeds and erases it, and clear the editor's undo history on a switch. A text send no longer removes a saved Article, and an Article that wasn't shown is not removed when the draft is cleared. The pending inline save and the Article editor window's content are stored on quit; the window also autosaves while open, only when its content changed. A draft with media blocks is edited inline, so Backspace no longer deletes the whole draft. * Respect the editor switch and keep editor drafts apart A saved Article isn't restored while the server turns the rich text editor off and is kept like one skipped because of input text. Emptying the editor window removes the saved copy, but nothing is saved before the window shows its draft. Quick reply, away, greeting and business link editors no longer share the chat's in-memory slot. The chat-date-context fixture and the key isolation test are updated.
1 parent cfb1fde commit 6be9999

11 files changed

Lines changed: 221 additions & 23 deletions

File tree

‎packages/ChatUI/Sources/ChatUI/ChatController.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8155,6 +8155,7 @@ open class ChatController<Context: AccountContextProtocol>: TelegramGenericViewC
81558155
}
81568156
guard let initialData = combinedInitialData.initialData else {
81578157
self.genericView.inputView.updateInterface(with: self.chatInteraction)
8158+
self.chatInteraction.loadPersistentRichTextDraft()
81588159
return
81598160
}
81608161

@@ -8295,6 +8296,7 @@ open class ChatController<Context: AccountContextProtocol>: TelegramGenericViewC
82958296

82968297

82978298
self.state = self.chatInteraction.presentation.state == .selecting ? .Edit : .Normal
8299+
self.chatInteraction.loadPersistentRichTextDraft()
82988300

82998301

83008302
} |> map {_ in}

‎packages/ChatUI/Sources/ChatUI/ChatInputView.swift‎

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public final class ChatInputContainerView: FlippableView {
9090
public var sendRichDraft: (() -> Void)?
9191
/// The inline editor's content grew/shrank → host propagates the new input height.
9292
public var richDraftHeightChanged: (() -> Void)?
93+
public var richDraftContentChanged: (() -> Void)?
9394

9495
public let accessory: ChatInputAccessory
9596
public let contentView: FlippableView
@@ -245,6 +246,9 @@ public final class ChatInputContainerView: FlippableView {
245246
current.onSend = { [weak self] in
246247
self?.sendRichDraft?()
247248
}
249+
current.onEditorContentChange = { [weak self] in
250+
self?.richDraftContentChanged?()
251+
}
248252
current.onEditorHeightChange = { [weak self] in
249253
guard let self = self, let preview = self.richDraftPreview else { return }
250254
// Editing emptied the draft (e.g. ⌘A + Delete removed all text AND media) → drop the rich
@@ -693,6 +697,8 @@ public class ChatInputView: View, Notifable {
693697
private let slowModeUntilDisposable = MetaDisposable()
694698
private let accessoryDisposable:MetaDisposable = MetaDisposable()
695699
private let richDraftDisposable = MetaDisposable()
700+
private let inlineRichDraftStoreDisposable = MetaDisposable()
701+
private var hasPendingInlineRichDraftStore = false
696702
private let incomingTypingDraftDisposable = MetaDisposable()
697703
private var incomingTypingDraftKey: PeerAndThreadId?
698704
private var canStopIncomingStreamingMessage = false
@@ -784,9 +790,17 @@ public class ChatInputView: View, Notifable {
784790
container.sendRichDraft = { [weak self] in
785791
self?.sendInlineRichDraft()
786792
}
793+
chatInteraction.storePendingRichTextDraft = { [weak self] in
794+
guard let self, self.hasPendingInlineRichDraftStore else {
795+
return
796+
}
797+
self.cancelInlineRichDraftStore()
798+
self.storeInlineRichDraft(threadId: self.chatInteraction.chatLocation.threadId)
799+
}
787800
// Just before any send reads the stored draft, write the live inline-editor content into it (or into
788801
// the composer text when the content reduced to plain) — so the send button posts the edited content.
789802
chatInteraction.flushRichTextDraft = { [weak self] in
803+
self?.cancelInlineRichDraftStore()
790804
guard let self = self, let preview = self.container.richDraftPreview, preview.isInlineEditing,
791805
let blocks = preview.currentOutgoing() else { return }
792806
switch composeRichMessage(from: blocks, media: preview.currentMedia()) {
@@ -810,6 +824,9 @@ public class ChatInputView: View, Notifable {
810824
container.richDraftHeightChanged = { [weak self] in
811825
self?.inputDidUpdateLayout(animated: true)
812826
}
827+
container.richDraftContentChanged = { [weak self] in
828+
self?.scheduleInlineRichDraftStore()
829+
}
813830
richDraftDisposable.set((chatInteraction.richTextDraftSignal |> deliverOnMainQueue).start(next: { [weak self] page in
814831
guard let self = self else { return }
815832
if self.container.setRichDraft(page, theme: self.currentChatTheme ?? theme) {
@@ -829,6 +846,29 @@ public class ChatInputView: View, Notifable {
829846
self.bindIncomingTypingDraft(for: chatInteraction.chatLocation, animated: false)
830847
}
831848

849+
private func scheduleInlineRichDraftStore() {
850+
hasPendingInlineRichDraftStore = true
851+
let threadId = chatInteraction.chatLocation.threadId
852+
inlineRichDraftStoreDisposable.set((Signal<Void, NoError>.single(Void()) |> delay(0.5, queue: .mainQueue())).start(completed: { [weak self] in
853+
self?.storeInlineRichDraft(threadId: threadId)
854+
}))
855+
}
856+
857+
private func cancelInlineRichDraftStore() {
858+
hasPendingInlineRichDraftStore = false
859+
inlineRichDraftStoreDisposable.set(nil)
860+
}
861+
862+
private func storeInlineRichDraft(threadId: Int64?) {
863+
hasPendingInlineRichDraftStore = false
864+
guard let preview = container.richDraftPreview, preview.isInlineEditing, let blocks = preview.currentOutgoing() else {
865+
return
866+
}
867+
if let page = richTextInstantPage(from: blocks, media: preview.currentMedia()) {
868+
chatInteraction.storeRichTextDraftSnapshot(page, threadId: threadId)
869+
}
870+
}
871+
832872
private func bindIncomingTypingDraft(for chatLocation: ChatLocation, animated: Bool) {
833873
let key = PeerAndThreadId(peerId: chatLocation.peerId, threadId: chatLocation.threadId)
834874
guard self.incomingTypingDraftKey != key else {
@@ -900,20 +940,13 @@ public class ChatInputView: View, Notifable {
900940
window.remove(object: self, for: .Z, modifierFlags: [.command], forceCheckFlags: true)
901941
window.remove(object: self, for: .Z, modifierFlags: [.command, .shift], forceCheckFlags: true)
902942

903-
// Whether the draft is shown in the LIVE inline editor (vs the read-only media preview). Matches the
904-
// preview's block-based decision (NOT `media.isEmpty`, which can be non-empty for a text-only draft):
905-
// a draft with no unsupported-media block is the live inline editor. Both cases get a window Return
943+
// Whether the draft is shown in the LIVE inline editor (vs the read-only preview). On macOS 12+ the
944+
// preview hosts every draft, media blocks included, in the inline editor. Both cases get a window Return
906945
// handler honoring the send preference; the inline case rejects non-send Returns so the editor adds a
907946
// newline, while Delete/⌘Z go to the editor.
908947
let isInlineEditable: Bool = {
909-
guard #available(macOS 12, *), let draft = chatInteraction.richTextDraft else { return false }
910-
let hasUninlineableBlock = draft.blocks.contains { block in
911-
switch block {
912-
case .video, .audio, .map, .collage, .slideshow: return true
913-
default: return false
914-
}
915-
}
916-
return !hasUninlineableBlock
948+
guard #available(macOS 12, *) else { return false }
949+
return chatInteraction.richTextDraft != nil
917950
}()
918951

919952
if isInlineEditable {
@@ -1232,6 +1265,16 @@ public class ChatInputView: View, Notifable {
12321265
if let value = value as? ChatPresentationInterfaceState {
12331266
if let oldValue = oldValue as? ChatPresentationInterfaceState, value.chatLocation != oldValue.chatLocation {
12341267
self.bindIncomingTypingDraft(for: value.chatLocation, animated: animated)
1268+
if value.chatLocation.peerId != oldValue.chatLocation.peerId || value.chatLocation.threadId != oldValue.chatLocation.threadId {
1269+
if self.hasPendingInlineRichDraftStore {
1270+
self.storeInlineRichDraft(threadId: oldValue.chatLocation.threadId)
1271+
}
1272+
self.cancelInlineRichDraftStore()
1273+
self.container.richDraftPreview?.reseedEditorOnNextUpdate()
1274+
self.deletedRichDraft = nil
1275+
self.chatInteraction.reloadRichTextDraft()
1276+
self.chatInteraction.loadPersistentRichTextDraft()
1277+
}
12351278
}
12361279
let sendState = ChatInputView.sendButtonState(
12371280
from: value,
@@ -2144,6 +2187,10 @@ public class ChatInputView: View, Notifable {
21442187

21452188

21462189
deinit {
2190+
if self.hasPendingInlineRichDraftStore {
2191+
self.storeInlineRichDraft(threadId: self.chatInteraction.chatLocation.threadId)
2192+
}
2193+
self.inlineRichDraftStoreDisposable.dispose()
21472194
self.accessoryDisposable.dispose()
21482195
self.rtfAttachmentsDisposable.dispose()
21492196
self.slowModeUntilDisposable.dispose()

‎packages/ChatUI/Sources/ChatUI/ChatInterfaceInteraction.swift‎

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -498,31 +498,101 @@ public final class ChatInteraction : InterfaceObserver {
498498
// composer (InstantPageV2) and sends as a `RichTextMessageAttribute`. nil when there is no rich draft.
499499
//
500500
// Backed by a process-global per-chat store so the draft survives the chat controller being torn down
501-
// and reopened (the `ChatInteraction` is recreated each time). (Full cross-launch/cross-device sync —
502-
// like iOS persisting it into the synchronizeable input state — is a follow-up.)
501+
// and reopened (the `ChatInteraction` is recreated each time), and kept in the item cache like iOS so it
502+
// survives a relaunch. (Cross-device sync is a follow-up.)
503503
private static let globalRichTextDrafts = ChatRichTextDraftStore<InstantPage>()
504504
private var richTextDraftKey: ChatRichTextDraftKey {
505+
return richTextDraftKey(threadId: chatLocation.threadId)
506+
}
507+
private func richTextDraftKey(threadId: Int64?) -> ChatRichTextDraftKey {
505508
let isWelcome: Bool
506509
if case .welcomeMessages = mode.customChatContents?.kind {
507510
isWelcome = true
508511
} else {
509512
isWelcome = false
510513
}
511-
return ChatRichTextDraftKey(accountId: context.account.id.int64, peerId: peerId.toInt64(), threadId: chatLocation.threadId, isWelcome: isWelcome)
514+
return ChatRichTextDraftKey(accountId: context.account.id.int64, peerId: peerId.toInt64(), threadId: threadId, isWelcome: isWelcome, isCustomEditor: mode.customChatContents != nil || mode.customChatLink != nil)
512515
}
513516
private let richTextDraftPromise = ValuePromise<InstantPage?>(nil, ignoreRepeated: false)
517+
private var persistentRichTextDraftId: EngineDataBuffer? {
518+
return persistentRichTextDraftId(threadId: chatLocation.threadId)
519+
}
520+
private func persistentRichTextDraftId(threadId: Int64?) -> EngineDataBuffer? {
521+
guard mode.customChatContents == nil, mode.customChatLink == nil else {
522+
return nil
523+
}
524+
let id = EngineDataBuffer(length: 16)
525+
id.setInt64(0, value: peerId.toInt64())
526+
id.setInt64(8, value: threadId ?? 0)
527+
return id
528+
}
514529
public var richTextDraft: InstantPage? { ChatInteraction.globalRichTextDrafts.value(for: richTextDraftKey) }
515530
public var richTextDraftSignal: Signal<InstantPage?, NoError> { richTextDraftPromise.get() }
516531
public var flushRichTextDraft: (() -> Void)?
517-
public func setRichTextDraft(_ page: InstantPage?) {
532+
public var storePendingRichTextDraft: (() -> Void)?
533+
public var storePendingRichTextEditorDraft: (() -> Void)?
534+
private var unshownPersistentRichTextDraftKey: ChatRichTextDraftKey?
535+
public func setRichTextDraft(_ page: InstantPage?, persist: Bool = true) {
518536
if ChatInteraction.globalRichTextDrafts.set(page, for: richTextDraftKey) {
519537
invalidateDeferredSendActions()
520538
}
521539
richTextDraftPromise.set(page)
540+
if persist, page != nil || unshownPersistentRichTextDraftKey != richTextDraftKey {
541+
unshownPersistentRichTextDraftKey = nil
542+
storePersistentRichTextDraft(page, threadId: chatLocation.threadId)
543+
}
544+
}
545+
public func storeRichTextDraftSnapshot(_ page: InstantPage, threadId: Int64?) {
546+
let key = richTextDraftKey(threadId: threadId)
547+
guard ChatInteraction.globalRichTextDrafts.value(for: key) != nil, ChatInteraction.globalRichTextDrafts.set(page, for: key) else {
548+
return
549+
}
550+
invalidateDeferredSendActions()
551+
storePersistentRichTextDraft(page, threadId: threadId)
552+
}
553+
public func persistRichTextDraft(_ page: InstantPage?) {
554+
guard unshownPersistentRichTextDraftKey != richTextDraftKey else {
555+
return
556+
}
557+
storePersistentRichTextDraft(page, threadId: chatLocation.threadId)
522558
}
523559
public func reloadRichTextDraft() {
524560
richTextDraftPromise.set(richTextDraft)
525561
}
562+
public func loadPersistentRichTextDraft() {
563+
guard richTextDraft == nil, let id = persistentRichTextDraftId else {
564+
return
565+
}
566+
let key = richTextDraftKey
567+
_ = (context.engine.data.get(TelegramEngine.EngineData.Item.ItemCache.Item(collectionId: ApplicationSpecificItemCacheCollectionId.richTextComposerDraft, id: id))
568+
|> deliverOnMainQueue).start(next: { [weak self] entry in
569+
guard let self, self.richTextDraftKey == key, let page = entry?.get(ChatRichTextDraftEntry.self)?.page else {
570+
return
571+
}
572+
guard self.context.appConfiguration.isRichTextEditorEnabled, self.presentation.effectiveInput.inputText.isEmpty else {
573+
self.unshownPersistentRichTextDraftKey = key
574+
return
575+
}
576+
guard ChatInteraction.globalRichTextDrafts.value(for: key) == nil else {
577+
return
578+
}
579+
self.unshownPersistentRichTextDraftKey = nil
580+
if ChatInteraction.globalRichTextDrafts.set(page, for: key) {
581+
self.invalidateDeferredSendActions()
582+
}
583+
self.richTextDraftPromise.set(page)
584+
})
585+
}
586+
private func storePersistentRichTextDraft(_ page: InstantPage?, threadId: Int64?) {
587+
guard let id = persistentRichTextDraftId(threadId: threadId) else {
588+
return
589+
}
590+
if let page {
591+
_ = context.engine.itemCache.put(collectionId: ApplicationSpecificItemCacheCollectionId.richTextComposerDraft, id: id, item: ChatRichTextDraftEntry(page: page)).start()
592+
} else {
593+
_ = context.engine.itemCache.remove(collectionId: ApplicationSpecificItemCacheCollectionId.richTextComposerDraft, id: id).start()
594+
}
595+
}
526596

527597

528598
public var hasSetDestructiveTimer: Bool {
@@ -1123,6 +1193,10 @@ public final class ChatInteraction : InterfaceObserver {
11231193

11241194

11251195
public func saveState(_ force:Bool = true, scrollState: ChatInterfaceHistoryScrollState? = nil, sync: Bool = false) {
1196+
if sync {
1197+
storePendingRichTextDraft?()
1198+
storePendingRichTextEditorDraft?()
1199+
}
11261200

11271201
if mode.customChatContents != nil || mode.customChatLink != nil {
11281202
return
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Foundation
2+
import Postbox
3+
import TelegramCore
4+
5+
final class ChatRichTextDraftEntry: Codable {
6+
let page: InstantPage
7+
8+
init(page: InstantPage) {
9+
self.page = page
10+
}
11+
12+
init(from decoder: Decoder) throws {
13+
let container = try decoder.container(keyedBy: StringCodingKey.self)
14+
let pageData = try container.decode(AdaptedPostboxDecoder.RawObjectData.self, forKey: "page")
15+
self.page = InstantPage(decoder: PostboxDecoder(buffer: MemoryBuffer(data: pageData.data)))
16+
}
17+
18+
func encode(to encoder: Encoder) throws {
19+
var container = encoder.container(keyedBy: StringCodingKey.self)
20+
try container.encode(PostboxEncoder().encodeObjectToRawData(self.page), forKey: "page")
21+
}
22+
}

‎packages/ChatUI/Sources/ChatUI/ChatRichTextDraftStore.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ struct ChatRichTextDraftKey: Hashable {
55
let peerId: Int64
66
let threadId: Int64?
77
let isWelcome: Bool
8+
var isCustomEditor: Bool = false
89
}
910

1011
final class ChatRichTextDraftRevision {

‎packages/ChatUI/Sources/ChatUI/ChatRichTextRowItem.swift‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,7 @@ public final class RichTextDraftPreviewView: View {
13301330
public var onSend: (() -> Void)?
13311331
/// Fired when the inline editor's content height changes (the host re-measures the row).
13321332
public var onEditorHeightChange: (() -> Void)?
1333+
public var onEditorContentChange: (() -> Void)?
13331334

13341335
@available(macOS 12, *)
13351336
private var richEditor: RichTextEditorView? { editorView as? RichTextEditorView }
@@ -1351,6 +1352,10 @@ public final class RichTextDraftPreviewView: View {
13511352

13521353
/// True while the live inline editor is shown (vs the read-only preview).
13531354
public var isInlineEditing: Bool { isEditable }
1355+
private var reseedsEditor = false
1356+
public func reseedEditorOnNextUpdate() {
1357+
reseedsEditor = true
1358+
}
13541359

13551360
/// Undo / redo on the inline editor (host wires ⌘Z / ⌘⇧Z to these).
13561361
public func undoInline() { if #available(macOS 12, *) { richEditor?.undo() } }
@@ -1425,6 +1430,7 @@ public final class RichTextDraftPreviewView: View {
14251430
return true
14261431
}
14271432
editor.onContentHeightChange = { [weak self] in self?.onEditorHeightChange?() }
1433+
editor.onFormatStateChange = { [weak self] in self?.onEditorContentChange?() }
14281434
// Render custom-emoji atoms as the app's animated inline stickers (same provider as the modal
14291435
// editor) — without this an inline draft's custom emoji draw as blanks.
14301436
if let context = self.chatInteraction?.interactionContext {
@@ -1520,10 +1526,15 @@ public final class RichTextDraftPreviewView: View {
15201526
// user is actively editing, the editor is the live source of truth — re-seeding (e.g. from our own
15211527
// draft-sync round-trip) would reset the caret.
15221528
let focused = (window?.firstResponder as? NSView).map { $0 === editor || $0.isDescendant(of: editor) } ?? false
1523-
if isNew || !focused {
1529+
if isNew || !focused || reseedsEditor {
1530+
let clearsUndo = reseedsEditor
1531+
reseedsEditor = false
15241532
let (blocks, media) = richTextOutgoing(from: instantPage)
15251533
self.mediaCache = media
15261534
editor.setOutgoingBlocks(blocks)
1535+
if clearsUndo {
1536+
editor.removeAllUndoActions()
1537+
}
15271538
editor.moveCaretToEnd()
15281539
}
15291540
return measureEditable(width: width)

0 commit comments

Comments
 (0)