Files
Telegram-iOS/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift
T
isaacisaacClaude Opus 4.7
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

550 lines
26 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import AnimationCache
import MultiAnimationRenderer
import AccountContext
import AsyncDisplayKit
import ComponentDisplayAdapters
import PagerComponent
import SwiftSignalKit
public final class EmojiSearchContent: ASDisplayNode, EntitySearchContainerNode {
private struct Params: Equatable {
var size: CGSize
var leftInset: CGFloat
var rightInset: CGFloat
var bottomInset: CGFloat
var inputHeight: CGFloat
var deviceMetrics: DeviceMetrics
}
private struct EmojiSearchResult {
var groups: [EmojiPagerContentComponent.ItemGroup]
var id: AnyHashable
var version: Int
var isPreset: Bool
}
private struct EmojiSearchState {
var result: EmojiSearchResult?
var isSearching: Bool
init(result: EmojiSearchResult?, isSearching: Bool) {
self.result = result
self.isSearching = isSearching
}
}
private let context: AccountContext
private let forceTheme: PresentationTheme?
private var initialFocusId: EngineItemCollectionId?
private let hasPremiumForUse: Bool
private let hasPremiumForInstallation: Bool
private let parentInputInteraction: EmojiPagerContentComponent.InputInteraction
private var presentationData: PresentationData
private let keyboardView = ComponentView<Empty>()
private let panelHostView: PagerExternalTopPanelContainer
private let inputInteractionHolder: EmojiPagerContentComponent.InputInteractionHolder
private var params: Params?
private var itemGroups: [EmojiPagerContentComponent.ItemGroup] = []
public var onCancel: (() -> Void)?
private let emojiSearchDisposable = MetaDisposable()
private let emojiSearchState = Promise<EmojiSearchState>(EmojiSearchState(result: nil, isSearching: false))
private var emojiSearchStateValue = EmojiSearchState(result: nil, isSearching: false) {
didSet {
self.emojiSearchState.set(.single(self.emojiSearchStateValue))
}
}
private var immediateEmojiSearchState: EmojiSearchState = EmojiSearchState(result: nil, isSearching: false)
private var dataDisposable: Disposable?
public init(
context: AccountContext,
forceTheme: PresentationTheme?,
items: [FeaturedStickerPackItem],
initialFocusId: EngineItemCollectionId?,
hasPremiumForUse: Bool,
hasPremiumForInstallation: Bool,
parentInputInteraction: EmojiPagerContentComponent.InputInteraction
) {
self.context = context
self.forceTheme = forceTheme
self.initialFocusId = initialFocusId
self.hasPremiumForUse = hasPremiumForUse
self.hasPremiumForInstallation = hasPremiumForInstallation
self.parentInputInteraction = parentInputInteraction
var presentationData = context.sharedContext.currentPresentationData.with { $0 }
if let forceTheme {
presentationData = presentationData.withUpdated(theme: forceTheme)
}
self.presentationData = presentationData
self.panelHostView = PagerExternalTopPanelContainer()
self.inputInteractionHolder = EmojiPagerContentComponent.InputInteractionHolder()
super.init()
for groupItem in items {
var groupItems: [EmojiPagerContentComponent.Item] = []
for item in groupItem.topItems {
var tintMode: EmojiPagerContentComponent.Item.TintMode = .none
if item.file.isCustomTemplateEmoji {
tintMode = .primary
}
let animationData = EntityKeyboardAnimationData(file: item.file)
let resultItem = EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: item.file,
subgroupId: nil,
icon: .none,
tintMode: tintMode
)
groupItems.append(resultItem)
}
self.itemGroups.append(EmojiPagerContentComponent.ItemGroup(
supergroupId: AnyHashable(groupItem.info.id),
groupId: AnyHashable(groupItem.info.id),
title: groupItem.info.title,
subtitle: nil,
badge: nil,
actionButtonTitle: self.presentationData.strings.EmojiInput_AddPack(groupItem.info.title).string,
isFeatured: true,
isPremiumLocked: !self.hasPremiumForInstallation,
isEmbedded: false,
hasClear: false,
hasEdit: false,
collapsedLineCount: 3,
displayPremiumBadges: false,
headerItem: nil,
fillWithLoadingPlaceholders: false,
items: groupItems
))
}
self.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction(
performItemAction: { [weak self] groupId, item, sourceView, sourceRect, sourceLayer, isPreview in
guard let self else {
return
}
self.parentInputInteraction.performItemAction(groupId, item, sourceView, sourceRect, sourceLayer, isPreview)
if self.hasPremiumForUse {
self.onCancel?()
}
},
deleteBackwards: {
},
openStickerSettings: {
},
openFeatured: {
},
openSearch: {
},
addGroupAction: { [weak self] groupId, isPremiumLocked, _ in
guard let self else {
return
}
self.parentInputInteraction.addGroupAction(groupId, isPremiumLocked, false)
if !isPremiumLocked {
if self.itemGroups.count == 1 {
self.onCancel?()
} else {
self.itemGroups.removeAll(where: { $0.groupId == groupId })
self.update(transition: ComponentTransition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(EmojiPagerContentComponent.ContentAnimation(type: .groupRemoved(id: groupId))))
}
}
},
clearGroup: { _ in
},
editAction: { _ in },
pushController: { _ in
},
presentController: { _ in
},
presentGlobalOverlayController: { _ in
},
navigationController: {
return nil
},
requestUpdate: { _ in
},
updateSearchQuery: { [weak self] query in
guard let self else {
return
}
switch query {
case .none:
self.emojiSearchDisposable.set(nil)
self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false)))
case let .text(rawQuery, languageCode):
let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
if query.isEmpty {
self.emojiSearchDisposable.set(nil)
self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false)))
} else {
let context = self.context
var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false)
if !languageCode.lowercased().hasPrefix("en") {
signal = signal
|> mapToSignal { keywords in
return .single(keywords)
|> then(
context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3)
|> map { englishKeywords in
return keywords + englishKeywords
}
)
}
}
let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))
|> map { peer -> Bool in
guard case let .user(user) = peer else {
return false
}
return user.isPremium
}
|> distinctUntilChanged
let resultSignal = signal
|> mapToSignal { keywords -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in
return combineLatest(
context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000),
context.engine.stickers.availableReactions(),
hasPremium
)
|> take(1)
|> map { view, availableReactions, hasPremium -> [EmojiPagerContentComponent.ItemGroup] in
var result: [(String, TelegramMediaFile.Accessor?, String)] = []
var allEmoticons: [String: String] = [:]
for keyword in keywords {
for emoticon in keyword.emoticons {
allEmoticons[emoticon] = keyword.keyword
}
}
for entry in view.entries {
guard let item = entry.item as? StickerPackItem else {
continue
}
if item.file.isCustomEmoji {
if !item.file.isPremiumEmoji || hasPremium {
if let alt = item.file.customEmojiAlt {
if !alt.isEmpty, let keyword = allEmoticons[alt] {
result.append((alt, item.file, keyword))
} else if alt == query {
result.append((alt, item.file, alt))
}
}
}
}
}
var items: [EmojiPagerContentComponent.Item] = []
var existingIds = Set<EngineMedia.Id>()
for item in result {
if let itemFile = item.1 {
if existingIds.contains(itemFile.fileId) {
continue
}
existingIds.insert(itemFile.fileId)
let animationData = EntityKeyboardAnimationData(file: itemFile)
let item = EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: itemFile, subgroupId: nil,
icon: .none,
tintMode: animationData.isTemplate ? .primary : .none
)
items.append(item)
}
}
return [EmojiPagerContentComponent.ItemGroup(
supergroupId: "search",
groupId: "search",
title: nil,
subtitle: nil,
badge: nil,
actionButtonTitle: nil,
isFeatured: false,
isPremiumLocked: false,
isEmbedded: false,
hasClear: false,
hasEdit: false,
collapsedLineCount: nil,
displayPremiumBadges: false,
headerItem: nil,
fillWithLoadingPlaceholders: false,
items: items
)]
}
}
var version = 0
self.emojiSearchStateValue.isSearching = true
self.emojiSearchDisposable.set((resultSignal
|> delay(0.15, queue: .mainQueue())
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false)
version += 1
}))
}
case let .category(value):
let resultSignal = self.context.engine.stickers.searchEmoji(category: value)
|> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in
var items: [EmojiPagerContentComponent.Item] = []
var existingIds = Set<EngineMedia.Id>()
for itemFile in files {
if existingIds.contains(itemFile.fileId) {
continue
}
existingIds.insert(itemFile.fileId)
let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(itemFile))
let item = EmojiPagerContentComponent.Item(
animationData: animationData,
content: .animation(animationData),
itemFile: TelegramMediaFile.Accessor(itemFile),
subgroupId: nil,
icon: .none,
tintMode: animationData.isTemplate ? .primary : .none
)
items.append(item)
}
return .single(([EmojiPagerContentComponent.ItemGroup(
supergroupId: "search",
groupId: "search",
title: nil,
subtitle: nil,
badge: nil,
actionButtonTitle: nil,
isFeatured: false,
isPremiumLocked: false,
isEmbedded: false,
hasClear: false,
hasEdit: false,
collapsedLineCount: nil,
displayPremiumBadges: false,
headerItem: nil,
fillWithLoadingPlaceholders: false,
items: items
)], isFinalResult))
}
let _ = resultSignal
var version = 0
self.emojiSearchDisposable.set((resultSignal
|> deliverOnMainQueue).start(next: { [weak self] result in
guard let self else {
return
}
guard let group = result.items.first else {
return
}
if group.items.isEmpty && !result.isFinalResult {
//self.emojiSearchStateValue.isSearching = true
self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [
EmojiPagerContentComponent.ItemGroup(
supergroupId: "search",
groupId: "search",
title: nil,
subtitle: nil,
badge: nil,
actionButtonTitle: nil,
isFeatured: false,
isPremiumLocked: false,
isEmbedded: false,
hasClear: false,
hasEdit: false,
collapsedLineCount: nil,
displayPremiumBadges: false,
headerItem: nil,
fillWithLoadingPlaceholders: true,
items: []
)
], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false)
return
}
self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: false), isSearching: false)
version += 1
}))
}
},
updateScrollingToItemGroup: {
},
externalCancel: { [weak self] in
guard let self else {
return
}
self.onCancel?()
},
onScroll: {},
chatPeerId: nil,
peekBehavior: nil,
customLayout: nil,
externalBackground: nil,
externalExpansionView: nil,
customContentView: nil,
useOpaqueTheme: true,
hideBackground: false,
stateContext: nil,
addImage: nil
)
self.dataDisposable = (
self.emojiSearchState.get()
|> deliverOnMainQueue
).start(next: { [weak self] emojiSearchState in
guard let self else {
return
}
self.immediateEmojiSearchState = emojiSearchState
self.update(transition: .immediate)
})
}
deinit {
self.emojiSearchDisposable.dispose()
self.dataDisposable?.dispose()
}
private func update(transition: ComponentTransition) {
if let params = self.params {
self.update(size: params.size, leftInset: params.leftInset, rightInset: params.rightInset, bottomInset: params.bottomInset, inputHeight: params.inputHeight, deviceMetrics: params.deviceMetrics, transition: transition)
}
}
public func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ContainedViewLayoutTransition) {
self.update(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics, transition: ComponentTransition(transition))
}
private func update(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, inputHeight: CGFloat, deviceMetrics: DeviceMetrics, transition: ComponentTransition) {
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
let params = Params(size: size, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, inputHeight: inputHeight, deviceMetrics: deviceMetrics)
self.params = params
var emojiContent = EmojiPagerContentComponent(
id: "emoji",
context: self.context,
avatarPeer: nil,
animationCache: self.context.animationCache,
animationRenderer: self.context.animationRenderer,
inputInteractionHolder: self.inputInteractionHolder,
panelItemGroups: [],
contentItemGroups: self.itemGroups,
itemLayoutType: .compact,
itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: "main", version: 0),
searchState: .empty(hasResults: false),
warpContentsOnEdges: false,
hideBackground: false,
maskEdge: .none,
displaySearchWithPlaceholder: self.presentationData.strings.EmojiSearch_SearchEmojiPlaceholder,
searchCategories: nil,
searchInitiallyHidden: false,
searchAlwaysActive: true,
searchIsPlaceholderOnly: false,
searchUnicodeEmojiOnly: false,
emptySearchResults: nil,
enableLongPress: false,
selectedItems: Set(),
customTintColor: nil
)
if let emojiSearchResult = self.immediateEmojiSearchState.result {
var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults?
if !emojiSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) {
emptySearchResults = EmojiPagerContentComponent.EmptySearchResults(
text: self.presentationData.strings.EmojiSearch_SearchEmojiEmptyResult,
iconFile: nil
)
}
emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: self.immediateEmojiSearchState.isSearching ? .searching : .empty(hasResults: true))
}
let _ = self.keyboardView.update(
transition: transition.withUserData(EmojiPagerContentComponent.SynchronousLoadBehavior(isDisabled: true)),
component: AnyComponent(EntityKeyboardComponent(
theme: self.presentationData.theme,
strings: self.presentationData.strings,
isContentInFocus: true,
containerInsets: UIEdgeInsets(top: 0.0, left: leftInset, bottom: bottomInset, right: rightInset),
topPanelInsets: UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0),
emojiContent: emojiContent,
stickerContent: nil,
maskContent: nil,
gifContent: nil,
hasRecentGifs: false,
availableGifSearchEmojies: [],
defaultToEmojiTab: true,
externalTopPanelContainer: self.panelHostView,
externalBottomPanelContainer: nil,
externalTintMaskContainer: nil,
displayTopPanelBackground: .blur,
topPanelExtensionUpdated: { _, _ in },
topPanelScrollingOffset: { _, _ in },
hideInputUpdated: { _, _, _ in },
hideTopPanelUpdated: { _, _ in
},
switchToTextInput: {},
switchToGifSubject: { _ in },
reorderItems: { _, _ in },
makeSearchContainerNode: { _ in return nil },
contentIdUpdated: { _ in },
deviceMetrics: deviceMetrics,
hiddenInputHeight: 0.0,
inputHeight: 0.0,
displayBottomPanel: false,
isExpanded: false,
clipContentToTopPanel: false,
useExternalSearchContainer: false,
hidePanels: true
)),
environment: {},
containerSize: size
)
if let keyboardComponentView = self.keyboardView.view as? EntityKeyboardComponent.View {
if keyboardComponentView.superview == nil {
self.view.addSubview(keyboardComponentView)
}
transition.setFrame(view: keyboardComponentView, frame: CGRect(origin: CGPoint(), size: size))
if let initialFocusId = self.initialFocusId {
self.initialFocusId = nil
keyboardComponentView.scrollToItemGroup(contentId: "emoji", groupId: AnyHashable(initialFocusId), subgroupId: nil, animated: false)
}
}
}
}