Telegram-iOS/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift
isaac 86d1456552 Postbox -> TelegramEngine waves 107-137 (squashed)
31 waves of consumer-side migration from `import Postbox` to TelegramEngine
typealiases. Net: 173 import drops + 39 BUILD-dep drops + 1 new typealias
(`EngineStoryId = StoryId`, wave 113).

Wave shapes used:
- Orphan-import sweeps (107, 108, 128): drop `import Postbox` from files
  whose only Postbox-symbol reference was the import line itself, then
  resolve build failures. Methodology requires token-level (`grep -oE`)
  filtering, not line-level, to avoid masking real Postbox usage on lines
  that also contain `Namespaces.X` references.
- Identifier-swap mini-waves (109-127, 129-134, 136-137): rename
  Postbox-typealiased identifiers to engine equivalents
  (PeerId -> EnginePeer.Id, MessageId -> EngineMessage.Id,
  MediaId -> EngineMedia.Id, MessageIndex -> EngineMessage.Index,
  StoryId -> EngineStoryId, ItemCollectionId -> EngineItemCollectionId,
  PreferencesEntry -> EnginePreferencesEntry,
  FetchResourceSourceType/Error -> EngineFetchResourceSourceType/Error,
  MemoryBuffer -> EngineMemoryBuffer, MessageTags -> EngineMessage.Tags,
  MessageAttribute -> EngineMessage.Attribute,
  TempBox -> EngineTempBox).
- Asset-string FP-only orphans (124).
- Typealias addition + drain (113): added `EngineStoryId` typealias to
  TelegramCore, then drained 3+11 consumer sites.

Hard blockers identified during these waves (must restore `import Postbox`
when present): MediaResource[A-Za-z]* (any suffix -- the literal
`MediaResource` matches don't catch MediaResourceData/MediaResourceId/etc.),
Postbox/MediaBox/MediaResource raw types, PostboxCoding/PostboxEncoder/
PostboxDecoder, TempBoxFile, ValueBoxKey, PostboxView, combinedView,
HashFunctions, postboxLog, openPostbox, declareEncodable, PeerView,
MessageHistoryView, MessageHistoryThreadData, CachedPeerData, RenderedPeer,
SelectivePrivacyPeer, SimpleDictionary, ItemCollectionInfosView,
ItemCollectionItem, ItemCollectionItemIndex, ItemCollectionViewEntryIndex,
ChatListIndex, ChatListEntrySummaryComponents, CodableEntry,
MessageHistoryThread, MessageHistoryAnchorIndex,
MessageHistoryEntryLocation, PeerStoryStats, PeerNameIndex,
PeerSummaryCounterTags, ChatListTotalUnreadStateCategory/Stats,
arePeersEqual. Protocol-shape blockers: bare `Peer`/`Message`/`Media`
in function signatures, generic args, enum-case payloads, or dict value
types (e.g., `[PeerId: Peer]`, `case messages([Message])`,
`Signal<(Peer?, ...), NoError>`).

`replace_all PeerId -> EnginePeer.Id` is dangerous: mangles compound
names like `failedPeerId`, `ContactListPeerId`, `nextRemoteMediaId`,
`replyToMessageId`. Pre-flight grep `\b[a-z][a-zA-Z]*PeerId\b` and only
replace_all if 0 matches.

Also removes unneeded design/plan docs from a separate (link-highlighting)
feature branch:
- docs/superpowers/plans/2026-05-02-link-highlighting-modern-path-fixes.md
- docs/superpowers/specs/2026-05-02-link-highlighting-modern-path-fixes-design.md

Squashed commits: 6d82c2980d..e6de5d53a3 (59 commits, including
per-wave content commits and per-wave CLAUDE.md bumps).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:28:50 +02:00

391 lines
18 KiB
Swift

import Foundation
import SwiftSignalKit
import Postbox
import TelegramCore
import AccountContext
import MultiplexedVideoNode
import EntityKeyboard
public final class ChatMediaInputGifPaneTrendingState {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?) {
self.files = files
self.nextOffset = nextOffset
}
}
public final class EntityKeyboardGifContent: Equatable {
public let hasRecentGifs: Bool
public let component: GifPagerContentComponent
public init(hasRecentGifs: Bool, component: GifPagerContentComponent) {
self.hasRecentGifs = hasRecentGifs
self.component = component
}
public static func ==(lhs: EntityKeyboardGifContent, rhs: EntityKeyboardGifContent) -> Bool {
if lhs.hasRecentGifs != rhs.hasRecentGifs {
return false
}
if lhs.component != rhs.component {
return false
}
return true
}
}
public class PaneGifSearchForQueryResult {
public let files: [MultiplexedVideoNodeFile]
public let nextOffset: String?
public let isComplete: Bool
public let isStale: Bool
public init(files: [MultiplexedVideoNodeFile], nextOffset: String?, isComplete: Bool, isStale: Bool) {
self.files = files
self.nextOffset = nextOffset
self.isComplete = isComplete
self.isStale = isStale
}
}
public func paneGifSearchForQuery(context: AccountContext, query: String, offset: String?, incompleteResults: Bool = false, staleCachedResults: Bool = false, delayRequest: Bool = true, updateActivity: ((Bool) -> Void)?) -> Signal<PaneGifSearchForQueryResult?, NoError> {
let contextBot = context.engine.data.get(TelegramEngine.EngineData.Item.Configuration.SearchBots())
|> mapToSignal { searchBots -> Signal<EnginePeer?, NoError> in
let botName = searchBots.gifBotUsername ?? "gif"
return context.engine.peers.resolvePeerByName(name: botName, referrer: nil)
|> mapToSignal { result in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}
}
|> mapToSignal { peer -> Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError> in
if case let .user(user) = peer, let botInfo = user.botInfo, let _ = botInfo.inlinePlaceholder {
let results = requestContextResults(engine: context.engine, botId: user.id, query: query, peerId: context.account.peerId, offset: offset ?? "", incompleteResults: incompleteResults, staleCachedResults: staleCachedResults, limit: 1)
|> map { results -> (ChatPresentationInputQueryResult?, Bool, Bool) in
return (.contextRequestResult(.user(user), results?.results), results != nil, results?.isStale ?? false)
}
let maybeDelayedContextResults: Signal<(ChatPresentationInputQueryResult?, Bool, Bool), NoError>
if delayRequest {
maybeDelayedContextResults = results |> delay(0.4, queue: Queue.concurrentDefaultQueue())
} else {
maybeDelayedContextResults = results
}
return maybeDelayedContextResults
} else {
return .single((nil, true, false))
}
}
return contextBot
|> mapToSignal { result -> Signal<PaneGifSearchForQueryResult?, NoError> in
if let r = result.0, case let .contextRequestResult(_, maybeCollection) = r, let collection = maybeCollection {
let results = collection.results
var references: [MultiplexedVideoNodeFile] = []
for result in results {
switch result {
case let .externalReference(externalReference):
var imageResource: TelegramMediaResource?
var thumbnailResource: TelegramMediaResource?
var thumbnailIsVideo: Bool = false
var uniqueId: Int64?
if let content = externalReference.content {
imageResource = content.resource
if let resource = content.resource as? WebFileReferenceMediaResource {
uniqueId = Int64(HashFunctions.murMurHash32(resource.url))
}
}
if let thumbnail = externalReference.thumbnail {
thumbnailResource = thumbnail.resource
if thumbnail.mimeType.hasPrefix("video/") {
thumbnailIsVideo = true
}
}
if externalReference.type == "gif", let resource = imageResource, let content = externalReference.content, let dimensions = content.dimensions {
var previews: [TelegramMediaImageRepresentation] = []
var videoThumbnails: [TelegramMediaFile.VideoThumbnail] = []
if let thumbnailResource = thumbnailResource {
if thumbnailIsVideo {
videoThumbnails.append(TelegramMediaFile.VideoThumbnail(
dimensions: dimensions,
resource: thumbnailResource
))
} else {
previews.append(TelegramMediaImageRepresentation(
dimensions: dimensions,
resource: thumbnailResource,
progressiveSizes: [],
immediateThumbnailData: nil,
hasVideo: false,
isPersonal: false
))
}
}
let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: uniqueId ?? 0), partialReference: nil, resource: resource, previewRepresentations: previews, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
case let .internalReference(internalReference):
if let file = internalReference.file {
references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result)))
}
}
}
return .single(PaneGifSearchForQueryResult(files: references, nextOffset: collection.nextOffset, isComplete: result.1, isStale: result.2))
} else if incompleteResults {
return .single(nil)
} else {
return .complete()
}
}
|> deliverOnMainQueue
|> beforeStarted {
updateActivity?(true)
}
|> afterCompleted {
updateActivity?(false)
}
}
public final class GifContext {
private var componentValue: EntityKeyboardGifContent? {
didSet {
if let componentValue = self.componentValue {
self.componentResult.set(.single(componentValue))
}
}
}
private let componentPromise = Promise<EntityKeyboardGifContent>()
private let componentResult = Promise<EntityKeyboardGifContent>()
public var component: Signal<EntityKeyboardGifContent, NoError> {
return self.componentResult.get()
}
private var componentDisposable: Disposable?
private let context: AccountContext
private let subject: GifPagerContentComponent.Subject
private let gifInputInteraction: GifPagerContentComponent.InputInteraction
private var loadingMoreToken: String?
public init(context: AccountContext, subject: GifPagerContentComponent.Subject, gifInputInteraction: GifPagerContentComponent.InputInteraction, trendingGifs: Signal<ChatMediaInputGifPaneTrendingState?, NoError>) {
self.context = context
self.subject = subject
self.gifInputInteraction = gifInputInteraction
let hideBackground = gifInputInteraction.hideBackground
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
switch subject {
case .recent:
gifItems = combineLatest(
context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs)),
searchCategories
)
|> map { savedGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
for gifItem in savedGifs {
items.append(GifPagerContentComponent.Item(
file: .savedGif(media: gifItem.contents.get(RecentMediaItem.self)!.media._parse()),
contextResult: nil
))
}
return EntityKeyboardGifContent(
hasRecentGifs: true,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: false,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case .trending:
gifItems = combineLatest(hasRecentGifs, trendingGifs, searchCategories)
|> map { hasRecentGifs, trendingGifs, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var isLoading = false
if let trendingGifs = trendingGifs {
for file in trendingGifs.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: nil,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .empty(hasResults: false),
hideBackground: hideBackground
)
)
}
case let .emojiSearch(query):
gifItems = combineLatest(
hasRecentGifs,
paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: nil, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil),
searchCategories
)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
loadMoreToken = result.nextOffset
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
}
self.componentPromise.set(gifItems)
self.componentDisposable = (self.componentPromise.get()
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let strongSelf = self else {
return
}
strongSelf.componentValue = result
})
}
deinit {
self.componentDisposable?.dispose()
}
public func loadMore(token: String) {
if self.loadingMoreToken == token {
return
}
self.loadingMoreToken = token
guard let componentValue = self.componentValue else {
return
}
let context = self.context
let subject = self.subject
let gifInputInteraction = self.gifInputInteraction
switch self.subject {
case let .emojiSearch(query):
let hasRecentGifs = context.engine.data.subscribe(TelegramEngine.EngineData.Item.OrderedLists.ListItems(collectionId: Namespaces.OrderedItemList.CloudRecentGifs))
|> map { savedGifs -> Bool in
return !savedGifs.isEmpty
}
let searchCategories: Signal<EmojiSearchCategories?, NoError> = context.engine.stickers.emojiSearchCategories(kind: .emoji)
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let gifItems: Signal<EntityKeyboardGifContent, NoError>
gifItems = combineLatest(hasRecentGifs, paneGifSearchForQuery(context: context, query: query.joined(separator: ""), offset: token, incompleteResults: true, staleCachedResults: true, delayRequest: false, updateActivity: nil), searchCategories)
|> map { hasRecentGifs, result, searchCategories -> EntityKeyboardGifContent in
var items: [GifPagerContentComponent.Item] = []
var existingIds = Set<EngineMedia.Id>()
for item in componentValue.component.items {
items.append(item)
existingIds.insert(item.file.media.fileId)
}
var loadMoreToken: String?
var isLoading = false
if let result = result {
for file in result.files {
if existingIds.contains(file.file.media.fileId) {
continue
}
existingIds.insert(file.file.media.fileId)
items.append(GifPagerContentComponent.Item(
file: file.file,
contextResult: file.contextResult
))
}
if !result.isComplete {
loadMoreToken = result.nextOffset
}
} else {
isLoading = true
}
return EntityKeyboardGifContent(
hasRecentGifs: hasRecentGifs,
component: GifPagerContentComponent(
context: context,
inputInteraction: gifInputInteraction,
subject: subject,
items: items,
isLoading: isLoading,
loadMoreToken: loadMoreToken,
displaySearchWithPlaceholder: gifInputInteraction.hasSearch ? presentationData.strings.Common_Search : nil,
searchCategories: searchCategories,
searchInitiallyHidden: true,
searchState: .active,
hideBackground: gifInputInteraction.hideBackground
)
)
}
self.componentPromise.set(gifItems)
default:
break
}
}
}