Skip to content

Commit 0509f25

Browse files
decoder-devcursoragent
authored andcommitted
Fix deleted-message saving: eager bridge, all delete paths, View Deleted
Root causes: MessageSavingBridge stayed disabled with a nil append sink until SharedAccountContext's async sharedData fired — cold-start / catch-up deletes were dropped. Snapshots only ran in replayFinalState, so TTL/autoremove, interactive, and secret-chat deletes via _internal_deleteMessages were missed. Empty-text (media-only) messages were skipped, and View Deleted was hidden unless hasDeleted was already true, so a broken pipeline looked like a missing feature. - Default bridge settings ON; install + apply eagerly before sharedData - Snapshot inside _internal_deleteMessages (deduped in store) - Media placeholders for photo/voice/sticker/etc. - Always show View Deleted when Save Deleted Messages is enabled
1 parent d46b468 commit 0509f25

6 files changed

Lines changed: 138 additions & 59 deletions

File tree

‎submodules/TelegramCore/Sources/MessageSaving/MessageSavingBridge.swift‎

Lines changed: 122 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,109 @@ public struct MessageSavingBridgeSettings: Equatable {
6363
self.saveForBots = saveForBots
6464
}
6565

66+
/// Matches ForkExtrasSettings.defaultSettings so deletes are captured before
67+
/// SharedAccountContext's async sharedData subscription applies persisted prefs.
68+
public static let defaults = MessageSavingBridgeSettings(saveDeleted: true, saveEdits: true, saveForBots: false)
69+
6670
public static let disabled = MessageSavingBridgeSettings(saveDeleted: false, saveEdits: false, saveForBots: false)
6771
}
6872

6973
/// TelegramCore cannot import TelegramUIPreferences; SharedAccountContext pushes settings + append sink here.
7074
public enum MessageSavingBridge {
71-
public static let settings = Atomic(value: MessageSavingBridgeSettings.disabled)
75+
public static let settings = Atomic(value: MessageSavingBridgeSettings.defaults)
7276
public static let append = Atomic<((MessageSavingRecord) -> Void)?>(value: nil)
7377

78+
/// Prefer a textual body; fall back to a short media placeholder so media-only
79+
/// deletes still appear in View Deleted (AyuGram is text-only, but empty skips
80+
/// looked like "saving is broken" for photo/sticker/voice messages).
81+
private static func displayText(for message: Message) -> String? {
82+
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
83+
if !text.isEmpty {
84+
return text
85+
}
86+
for media in message.media {
87+
if media is TelegramMediaImage {
88+
return "[Photo]"
89+
}
90+
if let file = media as? TelegramMediaFile {
91+
if file.isAnimatedSticker || file.isSticker {
92+
return "[Sticker]"
93+
}
94+
if file.isInstantVideo {
95+
return "[Video message]"
96+
}
97+
if file.isVoice {
98+
return "[Voice message]"
99+
}
100+
if file.isMusic {
101+
return "[Music]"
102+
}
103+
if file.isVideo {
104+
return "[Video]"
105+
}
106+
if file.isAnimated {
107+
return "[GIF]"
108+
}
109+
if let name = file.fileName, !name.isEmpty {
110+
return "[\(name)]"
111+
}
112+
return "[File]"
113+
}
114+
if media is TelegramMediaWebpage {
115+
return "[Link]"
116+
}
117+
if media is TelegramMediaMap {
118+
return "[Location]"
119+
}
120+
if media is TelegramMediaContact {
121+
return "[Contact]"
122+
}
123+
if media is TelegramMediaPoll {
124+
return "[Poll]"
125+
}
126+
if media is TelegramMediaDice {
127+
return "[Dice]"
128+
}
129+
if media is TelegramMediaGame {
130+
return "[Game]"
131+
}
132+
if media is TelegramMediaInvoice {
133+
return "[Invoice]"
134+
}
135+
if media is TelegramMediaAction {
136+
return nil
137+
}
138+
}
139+
return nil
140+
}
141+
142+
private static func authorName(for message: Message) -> String {
143+
if let user = message.author as? TelegramUser {
144+
let name = [user.firstName, user.lastName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
145+
return name.isEmpty ? "Unknown" : name
146+
}
147+
if let peer = message.author {
148+
let title = peer.debugDisplayTitle
149+
return title.isEmpty ? "Unknown" : title
150+
}
151+
return "Unknown"
152+
}
153+
154+
/// Incoming (MTProto `out` unset) or CountedAsIncoming — excludes the local user's
155+
/// own outgoing messages in every chat type, including channels.
156+
private static func shouldSave(message: Message, settings: MessageSavingBridgeSettings) -> Bool {
157+
if !message.flags.contains(.Incoming) && !message.flags.contains(.CountedAsIncoming) {
158+
return false
159+
}
160+
if let author = message.author as? TelegramUser, author.botInfo != nil, !settings.saveForBots {
161+
return false
162+
}
163+
if let peer = message.peers[message.id.peerId] as? TelegramUser, peer.botInfo != nil, !settings.saveForBots {
164+
return false
165+
}
166+
return true
167+
}
168+
74169
public static func snapshotMessages(
75170
transaction: Transaction,
76171
accountPeerId: PeerId,
@@ -89,30 +184,8 @@ public enum MessageSavingBridge {
89184
let now = Int32(Date().timeIntervalSince1970)
90185
for id in messageIds {
91186
guard let message = transaction.getMessage(id) else { continue }
92-
// message.id.peerId is the CHAT the message lives in, not its author — it only
93-
// equals accountPeerId for the Saved Messages self-chat. Use the Incoming flag to
94-
// correctly exclude the local account's own outgoing messages in every chat.
95-
if !message.flags.contains(.Incoming) {
96-
continue
97-
}
98-
if let author = message.author as? TelegramUser, author.botInfo != nil, !current.saveForBots {
99-
continue
100-
}
101-
if let peer = message.peers[message.id.peerId] as? TelegramUser, peer.botInfo != nil, !current.saveForBots {
102-
continue
103-
}
104-
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
105-
guard !text.isEmpty else { continue }
106-
107-
let author = message.author
108-
let authorName: String
109-
if let user = author as? TelegramUser {
110-
authorName = [user.firstName, user.lastName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
111-
} else if let peer = author {
112-
authorName = peer.debugDisplayTitle
113-
} else {
114-
authorName = ""
115-
}
187+
guard shouldSave(message: message, settings: current) else { continue }
188+
guard let text = displayText(for: message) else { continue }
116189

117190
let record = MessageSavingRecord(
118191
id: UUID().uuidString,
@@ -121,8 +194,8 @@ public enum MessageSavingBridge {
121194
messageId: message.id.id,
122195
namespace: message.id.namespace,
123196
date: message.timestamp,
124-
authorId: author.map { $0.id.toInt64() },
125-
authorName: authorName.isEmpty ? "Unknown" : authorName,
197+
authorId: message.author.map { $0.id.toInt64() },
198+
authorName: authorName(for: message),
126199
text: text,
127200
kind: kind,
128201
savedAt: now,
@@ -145,29 +218,8 @@ public enum MessageSavingBridge {
145218
guard current.saveEdits else { return }
146219
}
147220
guard let sink = append.with({ $0 }) else { return }
148-
// Same fix as snapshotMessages: check authorship via the Incoming flag, not whether
149-
// the chat itself is the Saved Messages self-chat.
150-
if !message.flags.contains(.Incoming) {
151-
return
152-
}
153-
if let author = message.author as? TelegramUser, author.botInfo != nil, !current.saveForBots {
154-
return
155-
}
156-
if let peer = message.peers[message.id.peerId] as? TelegramUser, peer.botInfo != nil, !current.saveForBots {
157-
return
158-
}
159-
let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
160-
guard !text.isEmpty else { return }
161-
162-
let author = message.author
163-
let authorName: String
164-
if let user = author as? TelegramUser {
165-
authorName = [user.firstName, user.lastName].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
166-
} else if let peer = author {
167-
authorName = peer.debugDisplayTitle
168-
} else {
169-
authorName = ""
170-
}
221+
guard shouldSave(message: message, settings: current) else { return }
222+
guard let text = displayText(for: message) else { return }
171223

172224
let record = MessageSavingRecord(
173225
id: UUID().uuidString,
@@ -176,13 +228,31 @@ public enum MessageSavingBridge {
176228
messageId: message.id.id,
177229
namespace: message.id.namespace,
178230
date: message.timestamp,
179-
authorId: author.map { $0.id.toInt64() },
180-
authorName: authorName.isEmpty ? "Unknown" : authorName,
231+
authorId: message.author.map { $0.id.toInt64() },
232+
authorName: authorName(for: message),
181233
text: text,
182234
kind: kind,
183235
savedAt: Int32(Date().timeIntervalSince1970),
184236
topicId: message.threadId ?? 0
185237
)
186238
sink(record)
187239
}
240+
241+
/// Resolve account peer from the postbox and snapshot before a local delete.
242+
/// Used by `_internal_deleteMessages` so TTL / interactive / secret-chat paths
243+
/// are covered even when they never go through `replayFinalState`.
244+
public static func snapshotDeletedMessages(
245+
transaction: Transaction,
246+
messageIds: [MessageId]
247+
) {
248+
guard let accountPeerId = (transaction.getState() as? AuthorizedAccountState)?.peerId else {
249+
return
250+
}
251+
snapshotMessages(
252+
transaction: transaction,
253+
accountPeerId: accountPeerId,
254+
messageIds: messageIds,
255+
kind: .deleted
256+
)
257+
}
188258
}

‎submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ func addMessageMediaResourceIdsToRemove(message: Message, resourceIds: inout [Me
2323
}
2424

2525
public func _internal_deleteMessages(transaction: Transaction, mediaBox: MediaBox, ids: [MessageId], deleteMedia: Bool = true, manualAddMessageThreadStatsDifference: ((MessageThreadKey, Int, Int) -> Void)? = nil) {
26+
// Snapshot before the messages leave Postbox. Covers TTL/autoremove, interactive
27+
// "delete for me", secret-chat deletes, and history validation — not only the
28+
// replayFinalState DeleteMessages path. Duplicate snapshots from that path are
29+
// deduped in MessageSavingStore.
30+
MessageSavingBridge.snapshotDeletedMessages(transaction: transaction, messageIds: ids)
31+
2632
var resourceIds: [MediaResourceId] = []
2733
if deleteMedia {
2834
for id in ids {

‎submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ extension PeerInfoScreenNode {
565565
}
566566
}
567567

568-
if strongSelf.context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages, MessageSavingStore.hasDeleted(accountPeerId: strongSelf.context.account.peerId.toInt64(), peerId: strongSelf.peerId.toInt64()) {
568+
if strongSelf.context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages {
569569
items.append(.action(ContextMenuActionItem(text: "View Deleted", icon: { theme in
570570
generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor)
571571
}, action: { [weak self] _, f in

‎submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,7 @@ extension ChatControllerImpl {
400400

401401
self?.beginMessageSearch("")
402402
})))
403-
if self.context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages,
404-
MessageSavingStore.hasDeleted(accountPeerId: self.context.account.peerId.toInt64(), peerId: peerId.toInt64()) {
403+
if self.context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages {
405404
items.append(.action(ContextMenuActionItem(text: "View Deleted", icon: { theme in
406405
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.contextMenu.primaryColor)
407406
}, action: { [weak self] action in

‎submodules/TelegramUI/Sources/ChatController.swift‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6008,8 +6008,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
60086008
f(.dismissWithoutContent)
60096009
self?.interfaceInteraction?.beginMessageSearch(.everything, "")
60106010
})))
6011-
if context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages,
6012-
MessageSavingStore.hasDeleted(accountPeerId: context.account.peerId.toInt64(), peerId: peer.id.toInt64()) {
6011+
if context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages {
60136012
items.append(.action(ContextMenuActionItem(text: "View Deleted", icon: { theme in
60146013
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.actionSheet.primaryTextColor)
60156014
}, action: { [weak self] _, f in
@@ -6271,8 +6270,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
62716270
f(.dismissWithoutContent)
62726271
self?.interfaceInteraction?.beginMessageSearch(.everything, "")
62736272
})))
6274-
if context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages,
6275-
MessageSavingStore.hasDeleted(accountPeerId: context.account.peerId.toInt64(), peerId: peerId.toInt64()) {
6273+
if context.sharedContext.immediateForkExtrasSettings.saveDeletedMessages {
62766274
items.append(.action(ContextMenuActionItem(text: "View Deleted", icon: { theme in
62776275
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Archive"), color: theme.actionSheet.primaryTextColor)
62786276
}, action: { [weak self] _, f in

‎submodules/TelegramUI/Sources/SharedAccountContext.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,12 @@ public final class SharedAccountContextImpl: SharedAccountContext {
538538
})
539539

540540
let immediateForkExtrasSettingsValue = self.immediateForkExtrasSettingsValue
541+
// Install the message-saving bridge *immediately* with defaults. The sharedData
542+
// signal below is async (and deliverOnMainQueue); account state sync can delete
543+
// messages before it fires — without an eager sink those snapshots were dropped
544+
// (settings stayed disabled / append was nil), so View Deleted stayed empty.
545+
MessageSavingStore.installBridge()
546+
MessageSavingStore.applySettings(immediateForkExtrasSettingsValue.with { $0 })
541547
self.forkExtrasSettingsDisposable = (self.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.forkExtrasSettings])
542548
|> deliverOnMainQueue).start(next: { sharedData in
543549
let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.forkExtrasSettings]?.get(ForkExtrasSettings.self) ?? .defaultSettings

0 commit comments

Comments
 (0)