Telegram-iOS/submodules/BrowserUI/Sources/BrowserAddressListItemComponent.swift
isaac 69bfc65da7 Postbox refactor waves 138–276: 168-commit squash
Consolidates 137 wave commits + 31 supporting commits (CLAUDE.md bump,
typealias additions, AnyObject→EngineMedia restoration) into one squashed
commit. Migrates dozens of consumer-side public APIs, struct fields,
protocol methods, and enum payloads from Postbox protocols/structs to
TelegramEngine engine wrappers and typealiases. Drops `import Postbox`
from many files. Adds new TelegramCore typealiases and one
TelegramEngineUnauthorized facade.

Notable changes by category:

**TelegramCore typealias additions** (rule 2 — narrow utility typealiases):
- EngineChatListIndex, EngineTempBoxFile, EngineItemCollectionItemIndex,
  EngineItemCollectionViewEntryIndex, EngineValueBoxEncryptionParameters,
  EngineMessageAndThreadId, EnginePeerStoryStats, EngineMessageHistoryAnchorIndex,
  EngineChatListTotalUnreadStateCategory, EngineChatListTotalUnreadStateStats,
  EnginePeerSummaryCounterTags, EngineChatListTotalUnreadState,
  EngineItemCacheEntryId, EngineHashFunctions,
  EngineCachedMediaResourceRepresentationResult,
  EngineMediaResourceDataFetchResult, EngineMediaResourceDataFetchError,
  EngineMediaResourceStatus, EngineCachedPeerData

**TelegramCore engine extensions/forwarders**:
- EngineMessage.engineMedia, EngineMessage.enginePeers,
  EngineMessage.adAttribute, EngineMessage.effectivelyIncoming
- engineFileSize forwarder
- TelegramEngine.Resources.clearCachedMediaResources(mediaResourceIds: Set<EngineMediaResource.Id>)
- TelegramEngine.Resources.fetchStatus(id:resourceSize:)
- TelegramEngineUnauthorized.UnauthorizedResources facade with storeResourceData

**Public API/struct migrations to engine types**:
- ChatAvailableMessageActions.banAuthor/banAuthors → EnginePeer?/[EnginePeer]
- WebSessionsContextState.peers → [EnginePeer.Id: EnginePeer]
- CacheUsageStats.peers → [EnginePeer.Id: EnginePeer]
- PeerCommand.peer → EnginePeer
- PeerInfoControllerMode.calls(messages:) → [EngineMessage]
- CallControllerNodeProtocol.updatePeer → EnginePeer params
- ChatHistoryListNode.messageInCurrentHistoryView (and 4 variants) → EngineMessage?
- ChatHistorySearchContainerNode.messageForGallery → EngineMessage?
- PeerInfoPaneNode.findLoadedMessage / ensureMessageIsVisible /
  transitionNodeForGallery → engine-typed
- GalleryHiddenMediaTarget.getTransitionInfo /
  GalleryHiddenMediaManager.findTarget → engine-typed
- ChatPanelInterfaceInteraction.presentReactionDeletionOptions /
  presentBan*MessageOptions → EnginePeer
- DrawingMessageRenderer.messages → [EngineMessage]
- ChatVideoGalleryItemScrubberView.setFetchStatusSignal →
  EngineMediaResource.FetchStatus
- ChannelDiscussionGroupActionSheetItem.peer, VoiceChatPeerEntry.peer,
  VoiceChatFullscreenParticipantItem.peer, MediaStreamComponent.chatPeer,
  MediaStreamVideoComponent.callPeer, ChatMessageContactBubbleContentNode.contactPeer,
  ChatMessageForwardInfoNode.peer, ChatMessageCommentFooterContentNode.replyPeers,
  ChatReportPeerTitlePanelNode.peer, ChatMessageActionUrlAuthController.bot,
  PeerMediaCollectionInterfaceState.peer, ChatMessageCallBubbleContentNode.peopleAvatars,
  ChatLoadingNode.renderedPeer (→ EngineRenderedPeer) — all to engine types

**Wave-71-shadow stored-field migrations** (Postbox Peer/Message → Engine wrapper):
- LegacyCallControllerNode.peer
- CallStatusBarNode.currentPeer

**Dead-code / dead-field removals**:
- CallController.peer, CallControllerNodeV2.account,
  ContactMultiselectionController PeerNameIndex fields,
  preparedChatListNodeViewTransition account: Account param,
  FetchResource.swift entirely (unused function)

**Module-level Postbox import drops**: 30+ files including TelegramRootController,
EditStories, GiftViewScreen, AnimatedStickerUtils, FetchPhotoLibraryImageResource,
PeerInfoGiftsPaneNode, PeerInfoPaneContainerNode, PresentAddMembers,
PeerInfoProfileItems, ChatControllerAdminBanUsers, PresentationData typealiases,
DefaultDayPresentationTheme, ChatListViewTransition, GalleryHiddenMediaManager,
RecentSessionsController, GifContext, AuthorizationSequenceController,
PeerInfoHeaderEditingContentNode, PeerInfoHeaderNode,
PeerAllowedReactionListController, CallControllerNodeV2, and 6 PeerInfo pane files.

**AnyObject restoration**: rule 8 added (never substitute Postbox protocols
with Any/AnyObject) — undid previous AnyObject substitutions in waves 141/143
back to EngineMedia.

Doc maintenance: CLAUDE.md updated to reflect new typealiases and forwarders.

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

370 lines
18 KiB
Swift

import Foundation
import UIKit
import Display
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import MultilineTextComponent
import TelegramPresentationData
import PhotoResources
import AccountContext
import ContextUI
import UrlEscaping
private let iconFont = Font.with(size: 28.0, design: .round, weight: .bold)
private let iconTextBackgroundImage = generateImage(CGSize(width: 40.0, height: 40.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(rgb: 0xFF9500).cgColor)
let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 12.0)
context.addPath(path.cgPath)
context.fillPath()
})
final class BrowserAddressListItemComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let webPage: TelegramMediaWebpage
var message: EngineMessage?
let hasNext: Bool
let insets: UIEdgeInsets
let action: () -> Void
let contextAction: ((TelegramMediaWebpage, EngineMessage?, ContextExtractedContentContainingView, ContextGesture) -> Void)?
init(
context: AccountContext,
theme: PresentationTheme,
webPage: TelegramMediaWebpage,
message: EngineMessage?,
hasNext: Bool,
insets: UIEdgeInsets,
action: @escaping () -> Void,
contextAction: ((TelegramMediaWebpage, EngineMessage?, ContextExtractedContentContainingView, ContextGesture) -> Void)?
) {
self.context = context
self.theme = theme
self.webPage = webPage
self.message = message
self.hasNext = hasNext
self.insets = insets
self.action = action
self.contextAction = contextAction
}
static func ==(lhs: BrowserAddressListItemComponent, rhs: BrowserAddressListItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.webPage != rhs.webPage {
return false
}
if lhs.hasNext != rhs.hasNext {
return false
}
if lhs.insets != rhs.insets {
return false
}
return true
}
final class View: ContextControllerSourceView {
private let extractedContainerView = ContextExtractedContentContainingView()
private let containerButton = HighlightTrackingButton()
private let separatorLayer = SimpleLayer()
private var highlightedBackgroundLayer = SimpleLayer()
private var emptyIcon: UIImageView?
private var emptyLabel: ComponentView<Empty>?
private var icon = TransformImageNode()
private let title = ComponentView<Empty>()
private let subtitle = ComponentView<Empty>()
private var isExtractedToContextMenu: Bool = false
private var component: BrowserAddressListItemComponent?
private weak var state: EmptyComponentState?
private var currentIconImageRepresentation: TelegramMediaImageRepresentation?
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.addSublayer(self.separatorLayer)
self.layer.addSublayer(self.highlightedBackgroundLayer)
self.addSubview(self.extractedContainerView)
self.targetViewForActivationProgress = self.extractedContainerView.contentView
self.highlightedBackgroundLayer.opacity = 0.0
self.extractedContainerView.contentView.addSubview(self.containerButton)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
self.containerButton.highligthedChanged = { [weak self] highlighted in
guard let self else {
return
}
if highlighted {
self.superview?.bringSubviewToFront(self)
self.highlightedBackgroundLayer.removeAnimation(forKey: "opacity")
self.highlightedBackgroundLayer.opacity = 1.0
} else {
self.highlightedBackgroundLayer.opacity = 0.0
self.highlightedBackgroundLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
}
}
self.extractedContainerView.isExtractedToContextPreviewUpdated = { [weak self] value in
guard let self, let component = self.component else {
return
}
self.containerButton.clipsToBounds = value
self.containerButton.backgroundColor = value ? component.theme.list.plainBackgroundColor : nil
self.containerButton.layer.cornerRadius = value ? 10.0 : 0.0
if value {
self.highlightedBackgroundLayer.opacity = 0.0
}
}
self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] value, transition in
guard let self else {
return
}
self.isExtractedToContextMenu = value
let mappedTransition: ComponentTransition
if value {
mappedTransition = ComponentTransition(transition)
} else {
mappedTransition = ComponentTransition(animation: .curve(duration: 0.2, curve: .easeInOut))
}
self.state?.updated(transition: mappedTransition)
}
self.activated = { [weak self] gesture, _ in
guard let self, let component = self.component else {
gesture.cancel()
return
}
component.contextAction?(component.webPage, component.message, self.extractedContainerView, gesture)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func pressed() {
guard let component = self.component else {
return
}
component.action()
}
func update(component: BrowserAddressListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let themeUpdated = self.component?.theme !== component.theme
let currentIconImageRepresentation = self.currentIconImageRepresentation
let iconSize = CGSize(width: 40.0, height: 40.0)
let height: CGFloat = 60.0
let leftInset: CGFloat = component.insets.left + 11.0 + iconSize.width + 11.0
let rightInset: CGFloat = 16.0
let titleSpacing: CGFloat = 2.0
let contextInset: CGFloat = self.isExtractedToContextMenu ? 12.0 : 0.0
let title: String
let subtitle: String
var parsedUrl: URL?
var iconImageReferenceAndRepresentation: (AnyMediaReference, TelegramMediaImageRepresentation)?
var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
if case let .Loaded(content) = component.webPage.content {
title = content.title ?? content.url
subtitle = getDisplayUrl(content.url)
parsedUrl = URL(string: content.url)
if let image = content.image {
if let representation = imageRepresentationLargerThan(image.representations, size: PixelDimensions(width: 80, height: 80)) {
if let message = component.message {
iconImageReferenceAndRepresentation = (.message(message: MessageReference(message._asMessage()), media: image), representation)
} else {
iconImageReferenceAndRepresentation = (.standalone(media: image), representation)
}
}
} else if let file = content.file {
if let representation = smallestImageRepresentation(file.previewRepresentations) {
if let message = component.message {
iconImageReferenceAndRepresentation = (.message(message: MessageReference(message._asMessage()), media: file), representation)
} else {
iconImageReferenceAndRepresentation = (.standalone(media: file), representation)
}
}
}
if currentIconImageRepresentation != iconImageReferenceAndRepresentation?.1 {
if let iconImageReferenceAndRepresentation = iconImageReferenceAndRepresentation {
if let imageReference = iconImageReferenceAndRepresentation.0.concrete(TelegramMediaImage.self) {
updateIconImageSignal = chatWebpageSnippetPhoto(account: component.context.account, userLocation: (component.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, photoReference: imageReference)
} else if let fileReference = iconImageReferenceAndRepresentation.0.concrete(TelegramMediaFile.self) {
updateIconImageSignal = chatWebpageSnippetFile(account: component.context.account, userLocation: (component.message?.id.peerId).flatMap(MediaResourceUserLocation.peer) ?? .other, mediaReference: fileReference.abstract, representation: iconImageReferenceAndRepresentation.1)
}
} else {
updateIconImageSignal = .complete()
}
}
} else {
title = ""
subtitle = ""
}
self.component = component
self.state = state
self.currentIconImageRepresentation = iconImageReferenceAndRepresentation?.1
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: title, font: Font.semibold(17.0), textColor: component.theme.list.itemPrimaryTextColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
let subtitleSize = self.subtitle.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: subtitle, font: Font.regular(15.0), textColor: component.theme.list.itemAccentColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftInset - rightInset, height: 100.0)
)
let centralContentHeight = titleSize.height + subtitleSize.height + titleSpacing
let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - centralContentHeight) / 2.0)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.containerButton.addSubview(titleView)
}
titleView.frame = titleFrame
}
let subtitleFrame = CGRect(origin: CGPoint(x: leftInset, y: titleFrame.maxY + titleSpacing), size: subtitleSize)
if let subtitleView = self.subtitle.view {
if subtitleView.superview == nil {
subtitleView.isUserInteractionEnabled = false
self.containerButton.addSubview(subtitleView)
}
subtitleView.frame = subtitleFrame
}
let iconFrame = CGRect(origin: CGPoint(x: 11.0 + component.insets.left, y: floorToScreenPixels((height - iconSize.height) / 2.0)), size: iconSize)
let iconImageLayout = self.icon.asyncLayout()
var iconImageApply: (() -> Void)?
if let iconImageReferenceAndRepresentation = iconImageReferenceAndRepresentation {
let imageCorners = ImageCorners(radius: 12.0, curve: .continuous)
let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconImageReferenceAndRepresentation.1.dimensions.cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: component.theme.list.mediaPlaceholderColor)
iconImageApply = iconImageLayout(arguments)
}
if let iconImageApply = iconImageApply {
if let updateImageSignal = updateIconImageSignal {
self.icon.setSignal(updateImageSignal)
}
if self.icon.supernode == nil {
self.containerButton.addSubview(self.icon.view)
self.icon.frame = iconFrame
} else {
transition.setFrame(view: self.icon.view, frame: iconFrame)
}
iconImageApply()
if let emptyIcon = self.emptyIcon {
self.emptyIcon = nil
emptyIcon.removeFromSuperview()
}
if let emptyLabel = self.emptyLabel {
self.emptyLabel = nil
emptyLabel.view?.removeFromSuperview()
}
} else {
if self.icon.supernode != nil {
self.icon.view.removeFromSuperview()
}
let icon: UIImageView
let label: ComponentView<Empty>
if let currentEmptyIcon = self.emptyIcon, let currentEmptyLabel = self.emptyLabel {
icon = currentEmptyIcon
label = currentEmptyLabel
} else {
icon = UIImageView()
icon.image = iconTextBackgroundImage
self.containerButton.addSubview(icon)
label = ComponentView()
}
icon.frame = iconFrame
var iconText = ""
if let parsedUrl, let host = parsedUrl.host {
if parsedUrl.path.hasPrefix("/addstickers/") {
iconText = "S"
} else if parsedUrl.path.hasPrefix("/addemoji/") {
iconText = "E"
} else {
iconText = host[..<host.index(after: host.startIndex)].uppercased()
}
}
let labelSize = label.update(
transition: .immediate,
component: AnyComponent(Text(text: iconText, font: iconFont, color: .white)),
environment: {},
containerSize: iconSize
)
let labelFrame = CGRect(origin: CGPoint(x: iconFrame.minX + floorToScreenPixels((iconFrame.width - labelSize.width) / 2.0), y: iconFrame.minY + floorToScreenPixels((iconFrame.height - labelSize.height) / 2.0)), size: labelSize)
if let labelView = label.view {
if labelView.superview == nil {
self.containerButton.addSubview(labelView)
}
labelView.frame = labelFrame
}
}
if themeUpdated {
self.highlightedBackgroundLayer.backgroundColor = component.theme.list.itemHighlightedBackgroundColor.cgColor
self.separatorLayer.backgroundColor = component.theme.list.itemPlainSeparatorColor.cgColor
}
transition.setFrame(layer: self.highlightedBackgroundLayer, frame: CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: height + UIScreenPixel)))
transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: leftInset, y: height), size: CGSize(width: availableSize.width - leftInset, height: UIScreenPixel)))
self.separatorLayer.isHidden = !component.hasNext
let containerFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: height))
transition.setFrame(view: self.containerButton, frame: containerFrame.insetBy(dx: contextInset, dy: 0.0))
transition.setFrame(view: self.extractedContainerView, frame: containerFrame)
transition.setFrame(view: self.extractedContainerView.contentView, frame: containerFrame)
self.extractedContainerView.contentRect = containerFrame
self.isGestureEnabled = component.contextAction != nil
return CGSize(width: availableSize.width, height: height)
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}