Telegram-iOS/submodules/GalleryData/Sources/GalleryData.swift
isaac 0050cc7a08 Rich-message media in gallery/shared-media/preview pipelines via Message.effectiveMedia
Add Message/EngineMessage.effectiveMedia (= message.media when non-empty, else
richText.instantPage.allMedia()) and route the media-consuming sites through it
so a rich message's instant-page media participates in the same pipelines as
normal message.media: shared-media grids/file-rows, search media grid, gallery
open + item nodes + footer, the peer audio/voice playlist, secret-media preview,
resource-by-id resolution, recent downloads, downloaded-media store, delete-time
resource cleanup, cache-usage stats, the in-chat download manager, and the
context-menu / share actions (Save to Camera Roll, copy image, save audio/music
to files). For normal messages effectiveMedia == message.media, so each swap is
behavior-preserving; rich messages render their own bubble via
ChatMessageRichDataBubbleContentNode (not the text/file bubbles), so those paths
are deliberately untouched, as are the forward path (the attribute travels with
the forward) and the markdown-based rich-edit path. First-media scope for now.

See docs/instantpage-richtext.md for the full architecture + invariants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:46:56 +02:00

377 lines
20 KiB
Swift

import Foundation
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import PassKit
import Lottie
import TelegramUIPreferences
import TelegramPresentationData
import AccountContext
import InstantPageUI
import PeerAvatarGalleryUI
import GalleryUI
import MediaResources
import WebsiteType
import StoryContainerScreen
public enum ChatMessageGalleryControllerData {
case url(String)
case pass(TelegramMediaFile)
case instantPage(InstantPageGalleryController, Int, EngineRawMedia)
case map(TelegramMediaMap)
case stickerPack(StickerPackReference, TelegramMediaFile?)
case audio(TelegramMediaFile)
case document(TelegramMediaFile, Bool)
case gallery(Signal<GalleryController, NoError>)
case secretGallery(SecretMediaPreviewController)
case chatAvatars(AvatarGalleryController, EngineRawMedia)
case theme(TelegramMediaFile)
case other(EngineRawMedia)
case story(Signal<StoryContainerScreen, NoError>)
}
private func instantPageBlockMedia(pageId: EngineMedia.Id, block: InstantPageBlock, media: [EngineMedia.Id: EngineRawMedia], counter: inout Int) -> [InstantPageGalleryEntry] {
switch block {
case let .image(id, caption, _, _):
if let m = media[id] {
let result = [InstantPageGalleryEntry(index: Int32(counter), pageId: pageId, media: InstantPageMedia(index: counter, media: EngineMedia(m), url: nil, caption: caption.text, credit: caption.credit), caption: caption.text, credit: caption.credit, location: InstantPageGalleryEntryLocation(position: Int32(counter), totalCount: 0))]
counter += 1
return result
}
case let .video(id, caption, _, _):
if let m = media[id] {
let result = [InstantPageGalleryEntry(index: Int32(counter), pageId: pageId, media: InstantPageMedia(index: counter, media: EngineMedia(m), url: nil, caption: caption.text, credit: caption.credit), caption: caption.text, credit: caption.credit, location: InstantPageGalleryEntryLocation(position: Int32(counter), totalCount: 0))]
counter += 1
return result
}
case let .collage(items, _):
var result: [InstantPageGalleryEntry] = []
for item in items {
result.append(contentsOf: instantPageBlockMedia(pageId: pageId, block: item, media: media, counter: &counter))
}
return result
case let .slideshow(items, _):
var result: [InstantPageGalleryEntry] = []
for item in items {
result.append(contentsOf: instantPageBlockMedia(pageId: pageId, block: item, media: media, counter: &counter))
}
return result
default:
break
}
return []
}
public func instantPageGalleryMedia(webpageId: EngineMedia.Id, page: InstantPage.Accessor, galleryMedia: EngineRawMedia) -> [InstantPageGalleryEntry] {
var result: [InstantPageGalleryEntry] = []
var counter: Int = 0
let page = page._parse()
for block in page.blocks {
result.append(contentsOf: instantPageBlockMedia(pageId: webpageId, block: block, media: page.media, counter: &counter))
}
var found = false
for item in result {
if item.media.media.id == galleryMedia.id {
found = true
break
}
}
if !found {
result.insert(InstantPageGalleryEntry(index: Int32(counter), pageId: webpageId, media: InstantPageMedia(index: counter, media: EngineMedia(galleryMedia), url: nil, caption: nil, credit: nil), caption: nil, credit: nil, location: InstantPageGalleryEntryLocation(position: Int32(counter), totalCount: 0)), at: 0)
}
for i in 0 ..< result.count {
let item = result[i]
result[i] = InstantPageGalleryEntry(index: Int32(i), pageId: item.pageId, media: item.media, caption: item.caption, credit: item.credit, location: InstantPageGalleryEntryLocation(position: Int32(i), totalCount: Int32(result.count)))
}
return result
}
public func chatMessageGalleryControllerData(
context: AccountContext,
chatLocation: ChatLocation?,
chatFilterTag: EngineMemoryBuffer?,
chatLocationContextHolder: Atomic<ChatLocationContextHolder?>?,
message: EngineRawMessage,
mediaSubject: GalleryMediaSubject? = nil,
navigationController: NavigationController?,
standalone: Bool,
reverseMessageGalleryOrder: Bool,
mode: ChatControllerInteractionOpenMessageMode,
source: GalleryControllerItemSource?,
synchronousLoad: Bool,
actionInteraction: GalleryControllerActionInteraction?
) -> ChatMessageGalleryControllerData? {
var standalone = standalone
if message.id.peerId.namespace == Namespaces.Peer.CloudUser && message.id.namespace != Namespaces.Message.Cloud {
standalone = true
}
var galleryMedia: EngineRawMedia?
var otherMedia: EngineRawMedia?
var instantPageMedia: (TelegramMediaWebpage, [InstantPageGalleryEntry])?
if message.media.isEmpty, let entities = message.textEntitiesAttribute?.entities, entities.count == 1, let firstEntity = entities.first, case let .CustomEmoji(_, fileId) = firstEntity.type, let file = message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile {
for attribute in file.attributes {
if case let .CustomEmoji(_, _, _, reference) = attribute {
if let reference = reference {
return .stickerPack(reference, file)
}
break
}
}
}
for media in message.effectiveMedia {
if let poll = media as? TelegramMediaPoll {
standalone = true
galleryMedia = poll
} else if let paidContent = media as? TelegramMediaPaidContent, let extendedMedia = paidContent.extendedMedia.first, case .full = extendedMedia {
standalone = true
galleryMedia = paidContent
} else if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia {
standalone = true
galleryMedia = fullMedia
} else if let action = media as? TelegramMediaAction {
switch action.action {
case let .photoUpdated(image), let .suggestedProfilePhoto(image):
if let peer = messageMainPeer(EngineMessage(message)), let image = image {
var isSuggested = false
if case .suggestedProfilePhoto = action.action {
isSuggested = true
}
let promise: Promise<[AvatarGalleryEntry]> = Promise([AvatarGalleryEntry.image(image.imageId, image.reference, image.representations.map({ ImageRepresentationWithReference(representation: $0, reference: .media(media: .message(message: MessageReference(message), media: media), resource: $0.resource)) }), image.videoRepresentations.map({ VideoRepresentationWithReference(representation: $0, reference: .media(media: .message(message: MessageReference(message), media: media), resource: $0.resource)) }), peer, message.timestamp, nil, message.id, image.immediateThumbnailData, "action", false, nil)])
let sourceCorners: AvatarGalleryController.SourceCorners
if case .photoUpdated = action.action {
sourceCorners = .roundRect(15.5)
} else {
sourceCorners = .round
}
let galleryController = AvatarGalleryController(context: context, peer: peer, sourceCorners: sourceCorners, remoteEntries: promise, isSuggested: isSuggested, skipInitial: true, replaceRootController: { controller, ready in
})
return .chatAvatars(galleryController, image)
}
default:
break
}
} else if let file = media as? TelegramMediaFile {
galleryMedia = file
} else if let image = media as? TelegramMediaImage {
galleryMedia = image
} else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
if let file = content.file {
galleryMedia = file
} else if let image = content.image {
if case .link = mode, !["video"].contains(content.type) {
} else if ["photo", "document", "video", "gif", "telegram_album"].contains(content.type) {
galleryMedia = image
}
}
if let instantPage = content.instantPage {
if case let .instantPageMedia(tappedMediaId) = mediaSubject {
let parsedPage = instantPage._parse()
if let tappedMedia = parsedPage.media[tappedMediaId] {
let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: tappedMedia)
if !medias.isEmpty {
instantPageMedia = (webpage, medias)
galleryMedia = tappedMedia
}
}
} else if let galleryMedia = galleryMedia {
switch instantPageType(of: content) {
case .album:
let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia)
if medias.count > 1 {
instantPageMedia = (webpage, medias)
}
default:
break
}
}
}
} else if let mapMedia = media as? TelegramMediaMap {
galleryMedia = mapMedia
} else if let contactMedia = media as? TelegramMediaContact {
otherMedia = contactMedia
}
}
var stream = false
var autoplayingVideo = false
var landscape = false
var timecode: Double? = nil
switch mode {
case .stream:
stream = true
case .automaticPlayback:
autoplayingVideo = true
case .landscape:
autoplayingVideo = true
landscape = true
case let .timecode(time):
timecode = time
default:
break
}
if let (webPage, instantPageMedia) = instantPageMedia, let galleryMedia = galleryMedia {
var centralIndex: Int = 0
for i in 0 ..< instantPageMedia.count {
if instantPageMedia[i].media.media.id == galleryMedia.id {
centralIndex = i
break
}
}
let gallery = InstantPageGalleryController(context: context, userLocation: chatLocation?.peerId.flatMap(MediaResourceUserLocation.peer) ?? .other, webPage: webPage, message: EngineMessage(message), entries: instantPageMedia, centralIndex: centralIndex, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: timecode, replaceRootController: { [weak navigationController] controller, ready in
if let navigationController = navigationController {
navigationController.replaceTopController(controller, animated: false, ready: ready)
}
}, baseNavigationController: navigationController)
return .instantPage(gallery, centralIndex, galleryMedia)
} else if let galleryMedia = galleryMedia {
var galleryMedia = galleryMedia
if let poll = galleryMedia as? TelegramMediaPoll {
if mediaSubject == nil || mediaSubject == .pollDescription, let attachedMedia = poll.attachedMedia {
if let file = attachedMedia as? TelegramMediaFile, file.isMusic {
galleryMedia = file
} else if let map = attachedMedia as? TelegramMediaMap {
galleryMedia = map
}
} else if case let .pollOption(opaqueIdentifier) = mediaSubject, let optionMedia = poll.options.first(where: { $0.opaqueIdentifier == opaqueIdentifier })?.media {
if let file = optionMedia as? TelegramMediaFile, file.isMusic {
galleryMedia = file
} else if let map = optionMedia as? TelegramMediaMap {
galleryMedia = map
}
} else if case .pollSolution = mediaSubject, let solutionMedia = poll.results.solution?.media {
if let file = solutionMedia as? TelegramMediaFile, file.isMusic {
galleryMedia = file
} else if let map = solutionMedia as? TelegramMediaMap {
galleryMedia = map
}
}
}
if let mapMedia = galleryMedia as? TelegramMediaMap {
return .map(mapMedia)
} else if let file = galleryMedia as? TelegramMediaFile, (file.isSticker || file.isAnimatedSticker) {
for attribute in file.attributes {
if case let .Sticker(_, reference, _) = attribute {
if let reference = reference {
return .stickerPack(reference, file)
}
break
}
}
} else if let file = galleryMedia as? TelegramMediaFile, file.isAnimatedSticker {
return nil
} else if let file = galleryMedia as? TelegramMediaFile, file.isMusic || file.isVoice || file.isInstantVideo {
return .audio(file)
} else if let file = galleryMedia as? TelegramMediaFile, file.mimeType == "application/vnd.apple.pkpass" || (file.fileName != nil && file.fileName!.lowercased().hasSuffix(".pkpass")) {
return .pass(file)
} else {
if let file = galleryMedia as? TelegramMediaFile {
if let fileName = file.fileName {
let ext = (fileName as NSString).pathExtension.lowercased()
if ext == "tgios-theme" {
return .theme(file)
} else if ext == "wav" || ext == "opus" {
return .audio(file)
}
/*if ext == "mkv" {
return .document(file, true)
}*/
}
var source = source
if standalone {
source = .standaloneMessage(message, nil)
}
if internalDocumentItemSupportsMimeType(file.mimeType, fileName: file.fileName ?? "file") {
let gallery = GalleryController(context: context, source: source ?? .peerMessagesAtId(messageId: message.id, chatLocation: chatLocation ?? .peer(id: message.id.peerId), customTag: chatFilterTag, chatLocationContextHolder: chatLocationContextHolder ?? Atomic<ChatLocationContextHolder?>(value: nil)), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: timecode, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in
navigationController?.replaceTopController(controller, animated: false, ready: ready)
}, baseNavigationController: navigationController, actionInteraction: actionInteraction)
return .gallery(.single(gallery))
}
if !file.isVideo {
return .document(file, false)
}
}
if let adAttribute = message.adAttribute, adAttribute.hasContentMedia {
let gallery = GalleryController(context: context, source: .standaloneMessage(message, mediaSubject), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: nil, playbackRate: 1.0, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in
navigationController?.replaceTopController(controller, animated: false, ready: ready)
}, baseNavigationController: navigationController, actionInteraction: actionInteraction)
gallery.temporaryDoNotWaitForReady = autoplayingVideo
return .gallery(.single(gallery))
} else if message.containsSecretMedia {
let gallery = SecretMediaPreviewController(context: context, messageId: message.id)
return .secretGallery(gallery)
} else {
let startState: Signal<(timecode: Double?, rate: Double), NoError>
if let timecode = timecode {
startState = .single((timecode: timecode, rate: 1.0))
} else {
startState = mediaPlaybackStoredState(engine: context.engine, messageId: message.id)
|> map { state in
return (state?.timestamp, state?.playbackRate.doubleValue ?? 1.0)
}
}
var openChatLocation = chatLocation ?? .peer(id: message.id.peerId)
var openChatLocationContextHolder = chatLocationContextHolder ?? Atomic<ChatLocationContextHolder?>(value: nil)
if chatLocation?.peerId != message.id.peerId {
openChatLocation = .peer(id: message.id.peerId)
openChatLocationContextHolder = Atomic<ChatLocationContextHolder?>(value: nil)
}
return .gallery(startState
|> deliverOnMainQueue
|> map { startState in
let gallery = GalleryController(context: context, source: source ?? (standalone ? .standaloneMessage(message, mediaSubject) : .peerMessagesAtId(messageId: message.id, chatLocation: openChatLocation, customTag: chatFilterTag, chatLocationContextHolder: openChatLocationContextHolder)), invertItemOrder: reverseMessageGalleryOrder, streamSingleVideo: stream, fromPlayingVideo: autoplayingVideo, landscape: landscape, timecode: startState.timecode, playbackRate: startState.rate, synchronousLoad: synchronousLoad, replaceRootController: { [weak navigationController] controller, ready in
navigationController?.replaceTopController(controller, animated: false, ready: ready)
}, baseNavigationController: navigationController, actionInteraction: actionInteraction)
gallery.temporaryDoNotWaitForReady = autoplayingVideo
return gallery
})
}
}
}
if let otherMedia = otherMedia {
return .other(otherMedia)
} else {
return nil
}
}
public enum ChatMessagePreviewControllerData {
case instantPage(InstantPageGalleryController, Int, EngineRawMedia)
case gallery(GalleryController)
}
public func chatMediaListPreviewControllerData(context: AccountContext, chatLocation: ChatLocation?, chatFilterTag: EngineMemoryBuffer?, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>?, message: EngineRawMessage, standalone: Bool, reverseMessageGalleryOrder: Bool, navigationController: NavigationController?) -> Signal<ChatMessagePreviewControllerData?, NoError> {
if let mediaData = chatMessageGalleryControllerData(context: context, chatLocation: chatLocation, chatFilterTag: chatFilterTag, chatLocationContextHolder: chatLocationContextHolder, message: message, navigationController: navigationController, standalone: standalone, reverseMessageGalleryOrder: reverseMessageGalleryOrder, mode: .default, source: nil, synchronousLoad: true, actionInteraction: nil) {
switch mediaData {
case let .gallery(gallery):
return gallery
|> map { gallery in
return .gallery(gallery)
}
case let .instantPage(gallery, centralIndex, galleryMedia):
return .single(.instantPage(gallery, centralIndex, galleryMedia))
default:
break
}
}
return .single(nil)
}