diff --git a/CLAUDE.md b/CLAUDE.md index 0a2defeb08..801a7996bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Rare exceptions: top-level view-controller views integrating with the system's f ## InstantPage V2 & rich-text messages -Typed markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, and the markdown send / edit / copy / paste paths — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md). +Typed markdown with structure the regular message-entity set can't represent (headings, lists, tables, formulas, nested blockquotes) is sent as a **rich message** — a `RichTextMessageAttribute` carrying an `InstantPage`, drawn by `ChatMessageRichDataBubbleContentNode` via the **InstantPage V2** renderer (with AI-streaming progressive reveal, inline custom emoji, and entity cases). The detailed architecture and non-obvious invariants — streaming reveal, V2 table/text-box layout, custom-emoji & entity round-trips, task-list checkboxes, nested blockquotes, thinking blocks, the markdown send / edit / copy / paste paths, and surfacing rich-message media through the shared-media/gallery/preview pipelines via `Message.effectiveMedia` — live in [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md). ## Postbox → TelegramEngine refactor (in progress) diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index 2f91a2962d..0ff5ae2b62 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -426,3 +426,44 @@ A server-sent rich message can arrive **partial** when the content is long: the - **`showMoreExpanded` is part of BOTH layout caches.** It is in the `currentPageLayout` cache key **and** the `pageView` content key (`pageViewMessageKey`). This is required because the cached-expand path (full page already on the attribute) performs **no postbox write**, so `stableVersion` does not bump — without the key, the cached partial layout/content would shadow the expand. - **Tap (`activateShowMore`):** if `fullInstantPage` is already cached → set expanded + `requestMessageUpdate` immediately (no network, no shimmer); otherwise shimmer the link and fetch, expanding only once the full page lands. Guards against a second in-flight request and against re-expanding. - **Expand grows the bubble downward in screen space** (top fixed) via `info?.setInvertOffsetDirection()` on the `ListViewItemApply` in the apply closure, fired only on the `appliedShowMoreExpanded → showMoreExpanded` transition (never on first apply). Same mechanism as `ChatMessageInteractiveFileNode`'s audio-transcription expand and the text/fact-check bubbles; the ListView clamps it to what fits. + +## Rich-message media in the gallery / shared-media / preview pipelines (`Message.effectiveMedia`) + +A rich message's media (images / videos / audio / documents) lives in `attribute.instantPage.media`, **not** in `message.media` (which is empty — rich messages are sent with `text: ""` and no media reference). To make that media participate in the *same* shared-media-index, gallery, file-list, playback, download, and save/copy pipelines that normal `message.media` flows through, there is one shared accessor and a set of opt-in call-site swaps. + +### The accessor + +`Message.effectiveMedia: [Media]` (+ a delegating `EngineMessage.effectiveMedia`) in `submodules/TelegramCore/Sources/Utils/MessageUtils.swift`: + +```swift +var effectiveMedia: [Media] { + if !self.media.isEmpty { return self.media } // normal message: identical to message.media + if let richText = self.richText { return richText.instantPage.allMedia() } // rich: the instant-page media + return self.media +} +``` + +`Message.richText` (same file) is already a typed `RichTextMessageAttribute?`; `InstantPage.allMedia()` (`SyncCore_InstantPage.swift`) recursively gathers media from the page's blocks (audio/collage/cover/details/image/list/slideshow/video) via its `[MediaId: Media]` dict. **For a normal message `effectiveMedia == message.media`**, so swapping a `message.media` read for `message.effectiveMedia` is behavior-preserving for non-rich content and only adds the rich media where the site should consider it. **Scope is first-media** for now (call sites keep their `.first` / iterate-and-break logic; the helper returns all media but callers stop at the first match — the `//TODO:rewrite to take all media` markers remain). + +### Where things live + +| Layer | What | +|---|---| +| **Discovery / index** | `tagsForStoreMessage` (`StoreMessage_Telegram.swift`) indexes rich media into `MessageTags` (photo/video/gif/voice/file). **This is the linchpin**: it makes rich messages *appear* in every tag-queried surface (shared-media tabs, search, downloads) — which is exactly why each rendering-side site below then needs `effectiveMedia`, or it renders the surfaced message blank. | +| **Extraction helper** | `Message.effectiveMedia` (above). | +| **Shared-media grids / rows** | `PeerInfoVisualMediaPaneNode`, `PeerInfoGifPaneNode`, `ListMessageItem` (row-type selection) + `ListMessageFileItemNode` (file/music/voice row), `ChatListSearchMediaNode` (search media grid). | +| **Gallery open + items** | `GalleryController` (`tagsForMessage` + `mediaForMessage` — the duplicated `message.media`/`message.richText` blocks were collapsed into one `effectiveMedia` loop), `GalleryData.chatMessageGalleryControllerData`, `SecretMediaPreviewController` (its own local `mediaForMessage`), and the gallery item nodes `ChatDocumentGalleryItem` / `ChatExternalFileGalleryItem` / `ChatAnimationGalleryItem` (these re-derive from `message.media` in `node()`, so a rich doc/animation rendered **blank** without the swap) + `UniversalVideoGalleryItem` secondary affordances + `ChatItemGalleryFooterContentNode`. | +| **Playback** | `PeerMessagesMediaPlaylist.extractFileMedia` (the peer music/voice playlist), `OverlayAudioPlayerControllerNode` (audio context menu). | +| **Resolution / downloads / cleanup** | `FetchedMediaResource.findMediaResourceById(message:)`, `SyncCore_RecentDownloadItem`, `StoreDownloadedMedia`, `DeleteMessages.addMessageMediaResourceIdsToRemove(message:)` (rich media was **leaking on delete**), `CollectCacheUsageStats`, `ChatHistoryListNode` (download manager), `ChatListSearch{ListPaneNode,ContainerNode}`. | +| **Actions** | `ChatInterfaceStateContextMenus` (Save-to-Camera-Roll, copy-image, save-audio/music-to-files, debug/premium), `ChatControllerNode` (post-suggestion media ref), `ChatControllerLoadDisplayNode` (edit send-validation), `ShareController.saveToCameraRoll`. | + +### Non-obvious invariants + +- **The tag-index change is what creates the work.** `tagsForStoreMessage` surfacing rich messages into tag-queried lists, *without* the rendering-side `effectiveMedia` swaps, produces visible **blank cells / blank rows / wrong row types**. Index and render must move together. +- **The rich message's own in-chat bubble + in-bubble gallery do NOT read `message.media`** — a rich message renders via `ChatMessageRichDataBubbleContentNode` (InstantPage V2), in-bubble image/video tap opens `InstantPageGalleryController` (reads the instant page directly), and in-bubble audio uses `InstantPageV2AudioContentNode`. So the text-bubble / interactive-file / interactive-media nodes' `message.media` reads are **never reached by a rich message** and are deliberately left alone. +- **Do NOT route the FORWARD path through `effectiveMedia`** (`ChatControllerNode` `forwardedMessages` ~556/560/568). The `RichTextMessageAttribute` already travels with a forward, so the forwarded copy reconstructs from the attribute; injecting the instant-page media as top-level `message.media` there would **double-render** (rich bubble + a separate media attachment). That `message.media` processing is caption-hiding / poll-stripping only, both irrelevant to rich — left as `message.media`. +- **Rich messages are edited as reconstructed MARKDOWN, not via the media-caption edit path.** So `ChatControllerLoadDisplayNode`'s edit caption-max-length / original-media-reference reads (~1241/1775/4463) stay on `message.media` — they belong to the `.media` edit state a rich message never enters. (The send-*validation* `.contains` at ~2273 IS swapped, so an edit that leaves only media isn't wrongly rejected.) +- **`RichTextMessageAttribute.associatedMediaIds` stays `[]` — intentionally.** `MessageHistoryTable` resolves `associatedMediaIds` via `getMedia(id)` in the postbox **media table**, but rich-message media is embedded inside the attribute blob, not the table — so returning the keys would be a no-op without also inserting the media into the table. The embedded-blob approach is self-contained. +- **`fullInstantPage` is not indexed** (the server doesn't index it either, and it's fetched on demand after store-time). The first media lives in the partial `instantPage` anyway. +- **Only switch the loop SOURCE, never the per-type branches.** Many swapped loops still contain `TelegramMediaPoll`/`TelegramMediaPaidContent`/`TelegramMediaWebpage` branches that rich messages never match — that's fine and intentional; only the `for … in .media` source changes. +- **Build-only completeness gate.** Every swap is type-identical (`[Media]` → `[Media]`), so the only compile risk is a receiver that is neither `Message` nor `EngineMessage`; the full Bazel build is the gate (no per-module build / unit tests). Deferred, NOT done: chat-list/reply/pinned/notification/forward thumbnail **previews** and the "Photo"/"Video" media-kind **labels** (`messageContentKind`/`ChatListItemStrings`) — those are preview surfaces, not blank-cell breakage — and **multi-media** (first-media-only is the current scope). diff --git a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift index 1cae0c3f76..60a7915a4b 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchContainerNode.swift @@ -1416,7 +1416,7 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo var resourceIds = Set() for message in messages { - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { resourceIds.insert(EngineMediaResource.Id(file.resource.id)) } diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 76ea9c9c4f..020fc458f5 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -2106,7 +2106,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { let queryTokens = stringIndexTokens(query ?? "", transliteration: .combined) func messageMatchesTokens(message: EngineMessage, tokens: [ValueBoxKey]) -> Bool { - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if let fileName = file.fileName { if matchStringIndexTokens(stringIndexTokens(fileName, transliteration: .none), with: tokens) { diff --git a/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift b/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift index 78b3dff62f..363194410f 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchMediaNode.swift @@ -117,7 +117,7 @@ private final class VisualMediaItemNode: ASDisplayNode { if case .tap = gesture { if let _ = self.item { var media: EngineRawMedia? - for value in message.media { + for value in message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -126,7 +126,7 @@ private final class VisualMediaItemNode: ASDisplayNode { break } } - + if let media = media { if let file = media as? TelegramMediaFile { if isMediaStreamable(message: EngineMessage(message), media: file) { @@ -150,7 +150,7 @@ private final class VisualMediaItemNode: ASDisplayNode { } var media: EngineRawMedia? - for value in message.media { + for value in message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -159,7 +159,7 @@ private final class VisualMediaItemNode: ASDisplayNode { break } } - + if let resourceStatus = self.resourceStatus, let file = media as? TelegramMediaFile { switch resourceStatus { case .Fetching: @@ -186,7 +186,7 @@ private final class VisualMediaItemNode: ASDisplayNode { self.theme = theme var media: EngineRawMedia? if let message = item.message { - for value in message.media { + for value in message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -413,7 +413,7 @@ private final class VisualMediaItem { var aspectRatio: CGFloat = 1.0 var dimensions = CGSize(width: 100.0, height: 100.0) - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if let dimensionsValue = file.dimensions, dimensions.height > 1 { dimensions = dimensionsValue.cgSize diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index b9c578e07c..f542f70350 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -125,7 +125,7 @@ public func chatMessageGalleryControllerData( } } } - for media in message.media { + for media in message.effectiveMedia { if let poll = media as? TelegramMediaPoll { standalone = true galleryMedia = poll diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 021fbd21c0..3ca685b5c4 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -870,7 +870,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll var canEdit = false var isImage = false var isVideo = false - for media in message.media { + for media in message.effectiveMedia { if media is TelegramMediaImage { canEdit = true isImage = true diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 0162ae83fb..018b5fc9f8 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -18,26 +18,27 @@ import UndoUI import TranslateUI private func tagsForMessage(_ message: Message) -> MessageTags? { - for media in message.media { + //TODO:rewrite to take all media (effectiveMedia returns all rich-text media; we stop at the first) + for media in message.effectiveMedia { switch media { - case _ as TelegramMediaImage: - return .photoOrVideo - case let file as TelegramMediaFile: - if file.isVideo { - if file.isAnimated { - return .gif - } else { - return .photoOrVideo - } - } else if file.isVoice { - return .voiceOrInstantVideo - } else if file.isSticker { - return nil + case _ as TelegramMediaImage: + return .photoOrVideo + case let file as TelegramMediaFile: + if file.isVideo { + if file.isAnimated { + return .gif } else { - return .file + return .photoOrVideo } - default: - break + } else if file.isVoice { + return .voiceOrInstantVideo + } else if file.isSticker { + return nil + } else { + return .file + } + default: + break } } return nil @@ -61,7 +62,8 @@ private func galleryMediaForMedia(media: Media) -> Media? { } func mediaForMessage(message: Message, mediaSubject: GalleryMediaSubject? = nil) -> [(Media, TelegramMediaImage?)] { - for media in message.media { + //TODO:rewrite to take all media (effectiveMedia returns all rich-text media; we return the first match) + for media in message.effectiveMedia { if let result = galleryMediaForMedia(media: media) { return [(result, nil)] } else if let poll = media as? TelegramMediaPoll { @@ -115,6 +117,7 @@ func mediaForMessage(message: Message, mediaSubject: GalleryMediaSubject? = nil) } } } + return [] } diff --git a/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift index e4a06bc893..8fe07decc8 100644 --- a/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatAnimationGalleryItem.swift @@ -33,7 +33,7 @@ class ChatAnimationGalleryItem: GalleryItem { func node(synchronous: Bool) -> GalleryItemNode { let node = ChatAnimationGalleryItemNode(context: self.context, presentationData: self.presentationData) - for media in self.message.media { + for media in self.message.effectiveMedia { if let file = media as? TelegramMediaFile { node.setFile(context: self.context, fileReference: .message(message: MessageReference(self.message), media: file)) break diff --git a/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift index c3f795abc0..b6e519f6c8 100644 --- a/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatDocumentGalleryItem.swift @@ -29,7 +29,7 @@ class ChatDocumentGalleryItem: GalleryItem { func node(synchronous: Bool) -> GalleryItemNode { let node = ChatDocumentGalleryItemNode(context: self.context, presentationData: self.presentationData) - for media in self.message.media { + for media in self.message.effectiveMedia { if let file = media as? TelegramMediaFile { node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file)) break diff --git a/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift index 88027cbb47..80aa995bef 100644 --- a/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatExternalFileGalleryItem.swift @@ -29,7 +29,7 @@ class ChatExternalFileGalleryItem: GalleryItem { func node(synchronous: Bool) -> GalleryItemNode { let node = ChatExternalFileGalleryItemNode(context: self.context, presentationData: self.presentationData) - for media in self.message.media { + for media in self.message.effectiveMedia { if let file = media as? TelegramMediaFile { node.setFile(context: context, fileReference: .message(message: MessageReference(self.message), media: file)) break diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index a70cc1f360..c21dab581d 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -1439,7 +1439,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if let content = item.content as? NativeVideoContent { isAnimated = content.fileReference.media.isAnimated self.videoFramePreview = MediaPlayerFramePreview(postbox: item.context.account.postbox, userLocation: content.userLocation, userContentType: .video, fileReference: content.fileReference) - if case let .message(message, _) = item.contentInfo, let _ = message.media.first(where: { $0 is TelegramMediaImage }) { + if case let .message(message, _) = item.contentInfo, let _ = message.effectiveMedia.first(where: { $0 is TelegramMediaImage }) { self.isLivePhoto = true disablePlayerControls = true isAnimated = false @@ -1635,7 +1635,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var file: TelegramMediaFile? var isWebpage = false - for m in message.media { + for m in message.effectiveMedia { if let m = m as? TelegramMediaFile, m.isVideo { file = m break @@ -1885,7 +1885,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.zoomableContent = (videoSize, videoNode) - if case let .message(message, _) = item.contentInfo, let content = item.content as? NativeVideoContent, let image = message.media.first(where: { $0 is TelegramMediaImage }), let imageReference = content.fileReference.abstract.withUpdatedMedia(image).concrete(TelegramMediaImage.self) { + if case let .message(message, _) = item.contentInfo, let content = item.content as? NativeVideoContent, let image = message.effectiveMedia.first(where: { $0 is TelegramMediaImage }), let imageReference = content.fileReference.abstract.withUpdatedMedia(image).concrete(TelegramMediaImage.self) { let imageNode = TransformImageNode() imageNode.alpha = 1.0 imageNode.isUserInteractionEnabled = false @@ -3134,7 +3134,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var hiddenMedia: (MessageId, Media)? = nil switch item.contentInfo { case let .message(message, _): - for media in message.media { + for media in message.effectiveMedia { if let media = media as? TelegramMediaImage { hiddenMedia = (message.id, media) } else if let media = media as? TelegramMediaFile, media.isVideo { @@ -3858,7 +3858,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { }))) } - if let (message, _, _) = strongSelf.contentInfo(), let image = message.media.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { + if let (message, _, _) = strongSelf.contentInfo(), let image = message.effectiveMedia.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil { let context = strongSelf.context var videoReference: AnyMediaReference? if let video = image.video { diff --git a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift index baf3a36d31..3390fbcace 100644 --- a/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift +++ b/submodules/GalleryUI/Sources/SecretMediaPreviewController.swift @@ -30,7 +30,7 @@ private func galleryMediaForMedia(media: Media) -> Media? { } private func mediaForMessage(message: Message) -> Media? { - for media in message.media { + for media in message.effectiveMedia { if let result = galleryMediaForMedia(media: media) { return result } else if let webpage = media as? TelegramMediaWebpage { diff --git a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift index 3d6e78d82e..83543e5cf4 100644 --- a/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift +++ b/submodules/ListMessageItem/Sources/ListMessageFileItemNode.swift @@ -682,7 +682,7 @@ public final class ListMessageFileItemNode: ListMessageNode { var selectedMedia: EngineRawMedia? if let message = message { - var effectiveMessageMedia = message.media + var effectiveMessageMedia = message.effectiveMedia for media in message.media { if let storyMedia = media as? TelegramMediaStory { if let story = message.associatedStories[storyMedia.storyId], !story.data.isEmpty, case let .item(storyItem) = story.get(Stories.StoredItem.self), let media = selectStoryMedia(item: storyItem, preferredHighQuality: item.interaction.preferredStoryHighQuality) { diff --git a/submodules/ListMessageItem/Sources/ListMessageItem.swift b/submodules/ListMessageItem/Sources/ListMessageItem.swift index e02baf6996..fd5296891a 100644 --- a/submodules/ListMessageItem/Sources/ListMessageItem.swift +++ b/submodules/ListMessageItem/Sources/ListMessageItem.swift @@ -145,7 +145,7 @@ public final class ListMessageItem: ListViewItem, ItemListItem { if !self.hintIsLink { if let message = self.message { - for media in message.media { + for media in message.effectiveMedia { if let _ = media as? TelegramMediaFile { viewClassName = ListMessageFileItemNode.self break diff --git a/submodules/ShareController/Sources/ShareController.swift b/submodules/ShareController/Sources/ShareController.swift index d45899e01c..b69ebe61d2 100644 --- a/submodules/ShareController/Sources/ShareController.swift +++ b/submodules/ShareController/Sources/ShareController.swift @@ -2400,7 +2400,7 @@ public final class ShareController: ViewController { let context = accountContext.context let signals: [Signal] = messages.compactMap { message -> Signal? in - if let media = message.media.first { + if let media = message.effectiveMedia.first { return SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) } else { return nil diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index ee197a83d8..1f6b2e0832 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -240,7 +240,7 @@ private func findMediaResource(media: Media, previousMedia: Media?, resource: Me } public func findMediaResourceById(message: EngineMessage, resourceId: MediaResourceId) -> TelegramMediaResource? { - for media in message.media { + for media in message.effectiveMedia { if let result = findMediaResourceById(media: media, resourceId: resourceId) { return result } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift index 1994313d47..dd306e9be9 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RecentDownloadItem.swift @@ -136,7 +136,7 @@ public func recentDownloadItems(postbox: Postbox) -> Signal<[RenderedRecentDownl } var size: Int64? - for media in message.media { + for media in message.effectiveMedia { if let result = findMediaResourceById(media: media, resourceId: MediaResourceId(item.resourceId)) { size = result.size break diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift index 84878e8508..2bed4870bd 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift @@ -17,7 +17,7 @@ func addMessageMediaResourceIdsToRemove(media: Media, resourceIds: inout [MediaR } func addMessageMediaResourceIdsToRemove(message: Message, resourceIds: inout [MediaResourceId]) { - for media in message.media { + for media in message.effectiveMedia { addMessageMediaResourceIdsToRemove(media: media, resourceIds: &resourceIds) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift index d068a22a93..7e68766b5b 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/CollectCacheUsageStats.swift @@ -736,7 +736,7 @@ func _internal_collectCacheUsageStats(account: Account, peerId: PeerId? = nil, a continue } if let message = transaction.getMessage(MessageId(peerId: PeerId(reference.peerId), namespace: MessageId.Namespace(reference.messageNamespace), id: reference.messageId)) { - for mediaItem in message.media { + for mediaItem in message.effectiveMedia { guard let mediaId = mediaItem.id else { continue } diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index 3d1ae582e7..e9251fee0f 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -663,6 +663,28 @@ public extension Message { } } +public extension Message { + /// The media that should drive gallery / shared-media / preview surfaces for this message. + /// For a normal message this is exactly `self.media`. For a rich message (`text == ""`, + /// empty `media`, carrying a `RichTextMessageAttribute`) the media lives inside the + /// instant page, so fall back to it. Scope note: callers take the FIRST media for now. + var effectiveMedia: [Media] { + if !self.media.isEmpty { + return self.media + } + if let richText = self.richText { + return richText.instantPage.allMedia() + } + return self.media + } +} + +public extension EngineMessage { + var effectiveMedia: [Media] { + return self._asMessage().effectiveMedia + } +} + public func _internal_parseMediaAttachment(data: Data) -> Media? { guard let object = Api.parse(Buffer(buffer: MemoryBuffer(data: data))) else { return nil diff --git a/submodules/TelegramUI/Components/MediaManager/PeerMessagesMediaPlaylist/Sources/PeerMessagesMediaPlaylist.swift b/submodules/TelegramUI/Components/MediaManager/PeerMessagesMediaPlaylist/Sources/PeerMessagesMediaPlaylist.swift index 16d7df1a56..bea6e89f13 100644 --- a/submodules/TelegramUI/Components/MediaManager/PeerMessagesMediaPlaylist/Sources/PeerMessagesMediaPlaylist.swift +++ b/submodules/TelegramUI/Components/MediaManager/PeerMessagesMediaPlaylist/Sources/PeerMessagesMediaPlaylist.swift @@ -34,7 +34,7 @@ struct MessageMediaPlaylistItemStableId: Hashable { private func extractFileMedia(_ message: EngineRawMessage) -> TelegramMediaFile? { var file: TelegramMediaFile? - for media in message.media { + for media in message.effectiveMedia { if let media = media as? TelegramMediaFile { file = media break diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift index 203de0c50a..5365b2f894 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoGifPaneNode.swift @@ -118,7 +118,7 @@ private final class VisualMediaItemNode: ASDisplayNode { if case .tap = gesture { if let (item, _, _, _) = self.item { var media: EngineRawMedia? - for value in item.message.media { + for value in item.message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -151,7 +151,7 @@ private final class VisualMediaItemNode: ASDisplayNode { } var media: EngineRawMedia? - for value in message.media { + for value in message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -183,7 +183,7 @@ private final class VisualMediaItemNode: ASDisplayNode { } self.theme = theme var media: EngineRawMedia? - for value in item.message.media { + for value in item.message.effectiveMedia { if let image = value as? TelegramMediaImage { media = image break @@ -425,7 +425,7 @@ private final class VisualMediaItem { var aspectRatio: CGFloat = 1.0 var dimensions = CGSize(width: 100.0, height: 100.0) - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if let dimensionsValue = file.dimensions, dimensions.height > 1 { dimensions = dimensionsValue.cgSize diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift index 5c073eee63..40b4fd0bf5 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift @@ -899,7 +899,7 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding, ListShimme layer.updateHasSpoiler(hasSpoiler: hasSpoiler) var selectedMedia: Media? - for media in message.media { + for media in message.effectiveMedia { if let image = media as? TelegramMediaImage { selectedMedia = image break diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index ce60db61b6..9934503998 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -2270,7 +2270,7 @@ extension ChatControllerImpl { if text.length == 0 { if strongSelf.presentationInterfaceState.editMessageState?.mediaReference != nil { - } else if message.media.contains(where: { media in + } else if message.effectiveMedia.contains(where: { media in switch media { case _ as TelegramMediaImage, _ as TelegramMediaFile, _ as TelegramMediaMap: return true diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 98a4403632..049bd55562 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -4848,7 +4848,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { mediaReference = mediaReferenceValue } else { if let message = self.historyNode.messageInCurrentHistoryView(editingOriginalMessageId)?._asMessage() { - for media in message.media { + for media in message.effectiveMedia { if media is TelegramMediaFile || media is TelegramMediaImage { mediaReference = .message(message: MessageReference(message), media: media) } diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 7cdc841d5e..8180d4f054 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -2962,7 +2962,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH } } - for media in message.media { + for media in message.effectiveMedia { if let _ = media as? TelegramMediaUnsupported { contentRequiredValidation = true } else if message.flags.contains(.Incoming), let media = media as? TelegramMediaMap, let liveBroadcastingTimeout = media.liveBroadcastingTimeout { diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 873d196f6f..e58c57e392 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -708,7 +708,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState var isGiveawayServiceMessage = false var diceEmoji: String? if messages.count == 1 { - for media in messages[0].media { + for media in messages[0].effectiveMedia { if let file = media as? TelegramMediaFile { if file.isSticker { loadStickerSaveStatus = file.fileId @@ -1088,7 +1088,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } if !hasRateTranscription && message.minAutoremoveOrClearTimeout == nil { - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile, let size = file.size, size < 1 * 1024 * 1024, let duration = file.duration, duration < 60, (["audio/mpeg", "audio/mp3", "audio/mpeg3", "audio/ogg"] as [String]).contains(file.mimeType.lowercased()) { let fileName = file.fileName ?? "Tone" @@ -1143,7 +1143,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if !isPremium && isDownloading { var isLargeFile = false - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if let size = file.size, size >= 150 * 1024 * 1024 { isLargeFile = true @@ -1298,7 +1298,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } var isExpired = false var isImage = false - for media in message.media { + for media in message.effectiveMedia { if let _ = media as? TelegramMediaExpiredContent { isExpired = true } @@ -1364,7 +1364,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState }) } if resourceAvailable { - for media in message.media { + for media in message.effectiveMedia { if let image = media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { let _ = (context.engine.resources.data(resource: EngineMediaResource(largest.resource), incremental: true) |> take(1) @@ -1454,7 +1454,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if resourceAvailable, !message.containsSecretMedia && !isCopyProtected { var mediaReference: AnyMediaReference? var isVideo = false - for media in message.media { + for media in message.effectiveMedia { if let image = media as? TelegramMediaImage, let _ = largestImageRepresentation(image.representations) { mediaReference = ImageMediaReference.standalone(media: image).abstract break @@ -1481,7 +1481,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } var downloadableMediaResourceInfos: [String] = [] - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if let info = extractMediaResourceDebugInfo(resource: file.resource) { downloadableMediaResourceInfos.append(info) @@ -1496,7 +1496,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } if !isCopyProtected { - for media in message.media { + for media in message.effectiveMedia { if let file = media as? TelegramMediaFile { if file.isMusic { actions.append(.action(ContextMenuActionItem(text: chatPresentationInterfaceState.strings.Conversation_SaveToFiles, icon: { theme in diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index e23bad464c..6ca193c38b 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -1366,7 +1366,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu } private func openMessageContextMenu(message: EngineRawMessage, node: ASDisplayNode, frame: CGRect, recognizer: TapLongTapOrDoubleTapGestureRecognizer? = nil, gesture: ContextGesture? = nil, location: CGPoint? = nil) { - guard let node = node as? ContextExtractedContentContainingNode, let peer = message.peers[message.id.peerId].flatMap({ PeerReference($0) }), let file = message.media.first(where: { $0 is TelegramMediaFile}) as? TelegramMediaFile else { + guard let node = node as? ContextExtractedContentContainingNode, let peer = message.peers[message.id.peerId].flatMap({ PeerReference($0) }), let file = message.effectiveMedia.first(where: { $0 is TelegramMediaFile}) as? TelegramMediaFile else { return } let context = self.context diff --git a/submodules/TelegramUI/Sources/StoreDownloadedMedia.swift b/submodules/TelegramUI/Sources/StoreDownloadedMedia.swift index 334123c010..ed5e1d3dc4 100644 --- a/submodules/TelegramUI/Sources/StoreDownloadedMedia.swift +++ b/submodules/TelegramUI/Sources/StoreDownloadedMedia.swift @@ -302,7 +302,7 @@ final class DownloadedMediaStoreManagerImpl: DownloadedMediaStoreManager { return } for item in items { - for media in item.message.media { + for media in item.message.effectiveMedia { if let id = media.id, id == item.mediaId { self.store(.standalone(media: media), timestamp: item.message.timestamp, peerId: item.message.id.peerId) break