Files
Telegram-iOS/submodules/TelegramUI/Components/MediaEditorScreen/Sources/CreateLinkOptions.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

235 lines
11 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import SwiftSignalKit
import TelegramCore
import ContextUI
import ChatPresentationInterfaceState
import AccountContext
import TelegramPresentationData
import WebsiteType
private enum OptionsId: Hashable {
case link
}
func presentLinkOptionsController(context: AccountContext, selfController: CreateLinkScreen, snapshotImage: UIImage?, isDark: Bool, sourceNode: ASDisplayNode, url: String, name: String, positionBelowText: Bool, largeMedia: Bool?, webPage: TelegramMediaWebpage, completion: @escaping (Bool, Bool?) -> Void, remove: @escaping () -> Void) {
var sources: [ContextController.Source] = []
if let source = linkOptions(context: context, selfController: selfController, snapshotImage: snapshotImage, isDark: isDark, sourceNode: sourceNode, url: url, text: name, positionBelowText: positionBelowText, largeMedia: largeMedia, webPage: webPage, completion: completion, remove: remove) {
sources.append(source)
}
if sources.isEmpty {
return
}
let contextController = makeContextController(
presentationData: context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkColorPresentationTheme),
configuration: ContextController.Configuration(
sources: sources,
initialId: AnyHashable(OptionsId.link)
)
)
selfController.presentInGlobalOverlay(contextController)
}
private func linkOptions(context: AccountContext, selfController: CreateLinkScreen, snapshotImage: UIImage?, isDark: Bool, sourceNode: ASDisplayNode, url: String, text: String, positionBelowText: Bool, largeMedia: Bool?, webPage: TelegramMediaWebpage, completion: @escaping (Bool, Bool?) -> Void, remove: @escaping () -> Void) -> ContextController.Source? {
let peerId = EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(1))
let presentationData = context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: defaultDarkColorPresentationTheme)
let initialUrlPreview = ChatPresentationInterfaceState.UrlPreview(url: url, webPage: webPage, positionBelowText: positionBelowText, largeMedia: largeMedia)
let urlPreview = ValuePromise<ChatPresentationInterfaceState.UrlPreview>(initialUrlPreview)
let linkOptions = urlPreview.get()
|> deliverOnMainQueue
|> map { urlPreview -> ChatControllerSubject.LinkOptions in
var webpageHasLargeMedia = false
if case let .Loaded(content) = webPage.content {
if let isMediaLargeByDefault = content.isMediaLargeByDefault {
if isMediaLargeByDefault {
webpageHasLargeMedia = true
}
} else {
webpageHasLargeMedia = true
}
}
let entities = [MessageTextEntity(range: 0 ..< (text as NSString).length, type: .Url)]
var largeMedia = false
if webpageHasLargeMedia {
if let value = urlPreview.largeMedia {
largeMedia = value
} else if case .Loaded = webPage.content {
largeMedia = false //!defaultWebpageImageSizeIsSmall(webpage: content)
} else {
largeMedia = true
}
} else {
largeMedia = false
}
return ChatControllerSubject.LinkOptions(
messageText: text,
messageEntities: entities,
hasAlternativeLinks: false,
replyMessageId: nil,
replyQuote: nil,
url: urlPreview.url,
webpage: urlPreview.webPage,
linkBelowText: urlPreview.positionBelowText,
largeMedia: largeMedia
)
}
|> distinctUntilChanged
let wallpaper: TelegramWallpaper?
if let image = snapshotImage {
let wallpaperResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
if let wallpaperData = image.jpegData(compressionQuality: 0.87) {
context.engine.resources.storeResourceData(id: EngineMediaResource.Id(wallpaperResource.id), data: wallpaperData, synchronous: true)
}
let wallpaperRepresentation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(image.size), resource: wallpaperResource, progressiveSizes: [], immediateThumbnailData: nil)
wallpaper = .image([wallpaperRepresentation], WallpaperSettings())
} else {
wallpaper = nil
}
let chatController = context.sharedContext.makeChatController(
context: context,
chatLocation: .peer(id: peerId),
subject: .messageOptions(peerIds: [peerId], ids: [], info: .link(ChatControllerSubject.MessageOptionsInfo.Link(options: linkOptions, isCentered: true))),
botStart: nil,
mode: .standard(.previewing),
params: ChatControllerParams(
forcedTheme: isDark ? defaultDarkColorPresentationTheme : defaultPresentationTheme,
forcedNavigationBarTheme: defaultDarkColorPresentationTheme,
forcedWallpaper: wallpaper
)
)
chatController.canReadHistory.set(false)
let items = linkOptions
|> deliverOnMainQueue
|> map { linkOptions -> ContextController.Items in
var items: [ContextMenuItem] = []
do {
items.append(.action(ContextMenuActionItem(text: linkOptions.linkBelowText ? presentationData.strings.Conversation_MessageOptionsLinkMoveUp : presentationData.strings.Conversation_MessageOptionsLinkMoveDown, icon: { theme in
return nil
}, iconAnimation: ContextMenuActionItem.IconAnimation(
name: linkOptions.linkBelowText ? "message_preview_sort_above" : "message_preview_sort_below"
), action: { _, f in
let _ = (urlPreview.get()
|> take(1)).start(next: { current in
var updatedUrlPreview = current
updatedUrlPreview.positionBelowText = !current.positionBelowText
urlPreview.set(updatedUrlPreview)
})
})))
}
if case let .Loaded(content) = linkOptions.webpage.content, let isMediaLargeByDefault = content.isMediaLargeByDefault, isMediaLargeByDefault {
let shrinkTitle: String
let enlargeTitle: String
if let file = content.file, file.isVideo {
shrinkTitle = presentationData.strings.Conversation_MessageOptionsShrinkVideo
enlargeTitle = presentationData.strings.Conversation_MessageOptionsEnlargeVideo
} else {
shrinkTitle = presentationData.strings.Conversation_MessageOptionsShrinkImage
enlargeTitle = presentationData.strings.Conversation_MessageOptionsEnlargeImage
}
items.append(.action(ContextMenuActionItem(text: linkOptions.largeMedia ? shrinkTitle : enlargeTitle, icon: { _ in
return nil
}, iconAnimation: ContextMenuActionItem.IconAnimation(
name: !linkOptions.largeMedia ? "message_preview_media_large" : "message_preview_media_small"
), action: { _, f in
let _ = (urlPreview.get()
|> take(1)).start(next: { current in
var updatedUrlPreview = current
if let largeMedia = current.largeMedia {
updatedUrlPreview.largeMedia = !largeMedia
} else {
updatedUrlPreview.largeMedia = false
}
urlPreview.set(updatedUrlPreview)
})
})))
}
if !items.isEmpty {
items.append(.separator)
}
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_MessageOptionsApplyChanges, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { _, f in
f(.default)
let _ = (urlPreview.get()
|> take(1)).start(next: { current in
completion(current.positionBelowText, current.largeMedia)
})
})))
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_LinkOptionsCancel, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in
remove()
f(.default)
})))
return ContextController.Items(id: AnyHashable(linkOptions.url), content: .list(items))
}
return ContextController.Source(
id: AnyHashable(OptionsId.link),
title: presentationData.strings.Conversation_MessageOptionsTabLink,
source: .controller(ChatContextControllerContentSourceImpl(controller: chatController, sourceNode: sourceNode, passthroughTouches: true)),
items: items
)
}
final class ChatContextControllerContentSourceImpl: ContextControllerContentSource {
let controller: ViewController
weak var sourceNode: ASDisplayNode?
weak var sourceView: UIView?
let sourceRect: CGRect?
let navigationController: NavigationController? = nil
let passthroughTouches: Bool
init(controller: ViewController, sourceNode: ASDisplayNode?, sourceRect: CGRect? = nil, passthroughTouches: Bool) {
self.controller = controller
self.sourceNode = sourceNode
self.sourceRect = sourceRect
self.passthroughTouches = passthroughTouches
}
init(controller: ViewController, sourceView: UIView?, sourceRect: CGRect? = nil, passthroughTouches: Bool) {
self.controller = controller
self.sourceView = sourceView
self.sourceRect = sourceRect
self.passthroughTouches = passthroughTouches
}
func transitionInfo() -> ContextControllerTakeControllerInfo? {
let sourceView = self.sourceView
let sourceNode = self.sourceNode
let sourceRect = self.sourceRect
return ContextControllerTakeControllerInfo(contentAreaInScreenSpace: CGRect(origin: CGPoint(), size: CGSize(width: 10.0, height: 10.0)), sourceNode: { [weak sourceNode] in
if let sourceView = sourceView {
return (sourceView, sourceRect ?? sourceView.bounds)
} else if let sourceNode = sourceNode {
return (sourceNode.view, sourceRect ?? sourceNode.bounds)
} else {
return nil
}
})
}
func animatedIn() {
}
}