Telegram-iOS/submodules/TelegramCallsUI/Sources/Components/MessageItemComponent.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

512 lines
23 KiB
Swift

import Foundation
import Display
import UIKit
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import AvatarNode
import GlassBackgroundComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import AccountContext
import TextFormat
import TelegramPresentationData
import ReactionSelectionNode
import BundleIconComponent
import LottieComponent
import Markdown
private let glassColor = UIColor(rgb: 0x25272e, alpha: 0.72)
final class MessageItemComponent: Component {
public enum Icon: Equatable {
case peer(EnginePeer)
case icon(String)
case animation(String)
}
public enum Style {
case generic
case notification
case status
}
private let context: AccountContext
private let icon: Icon
private let style: Style
private let text: String
private let entities: [MessageTextEntity]
private let availableReactions: [ReactionItem]?
private let openPeer: ((EnginePeer) -> Void)?
init(
context: AccountContext,
icon: Icon,
style: Style,
text: String,
entities: [MessageTextEntity],
availableReactions: [ReactionItem]?,
openPeer: ((EnginePeer) -> Void)?
) {
self.context = context
self.icon = icon
self.style = style
self.text = text
self.entities = entities
self.availableReactions = availableReactions
self.openPeer = openPeer
}
static func == (lhs: MessageItemComponent, rhs: MessageItemComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.style != rhs.style {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if (lhs.availableReactions ?? []).isEmpty != (rhs.availableReactions ?? []).isEmpty {
return false
}
return true
}
final class View: UIView {
private let container: UIView
private let background: GlassBackgroundView
private let avatarNode: AvatarNode
private let icon: ComponentView<Empty>
private let text: ComponentView<Empty>
weak var standaloneReactionAnimation: StandaloneReactionAnimation?
private var cachedEntities: [MessageTextEntity]?
private var entityFiles: [EngineMedia.Id: TelegramMediaFile] = [:]
private var component: MessageItemComponent?
override init(frame: CGRect) {
self.container = UIView()
self.container.transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
self.background = GlassBackgroundView()
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0))
self.avatarNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -8.0)
self.icon = ComponentView()
self.text = ComponentView()
super.init(frame: frame)
self.addSubview(self.container)
self.container.addSubview(self.background)
self.container.addSubview(self.avatarNode.view)
self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.avatarTapped)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func avatarTapped() {
guard let component = self.component, case let .peer(peer) = component.icon else {
return
}
component.openPeer?(peer)
}
func animateFrom(globalFrame: CGRect, cornerRadius: CGFloat, textSnapshotView: UIView, transition: ComponentTransition) {
guard let superview = self.superview?.superview?.superview else {
return
}
let originalCenter = self.container.center
let originalTransform = self.container.transform
let superviewCenter = self.convert(self.container.center, to: superview)
self.container.center = superviewCenter
self.container.transform = .identity
superview.addSubview(self.container)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let direction: CGFloat = hasRTL ? -1.0 : 1.0
let initialSize = self.background.frame.size
self.container.addSubview(textSnapshotView)
transition.setAlpha(view: textSnapshotView, alpha: 0.0, completion: { _ in
textSnapshotView.removeFromSuperview()
})
let additionalOffset = hasRTL ? globalFrame.size.width - initialSize.width : 0.0
transition.setPosition(view: textSnapshotView, position: CGPoint(x: textSnapshotView.center.x + 71.0 * direction - additionalOffset, y: textSnapshotView.center.y))
self.background.update(size: globalFrame.size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: .immediate)
self.background.update(size: initialSize, cornerRadius: 18.0, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: transition)
let deltaX = (globalFrame.width - self.container.frame.width) / 2.0
let deltaY = (globalFrame.height - self.container.frame.height) / 2.0
let fromFrame = superview.convert(globalFrame, from: nil).offsetBy(dx: -deltaX, dy: -deltaY)
self.container.center = fromFrame.center
transition.setPosition(view: self.container, position: superviewCenter, completion: { _ in
self.container.center = originalCenter
self.container.transform = originalTransform
self.insertSubview(self.container, at: 0)
})
if let textView = self.text.view {
transition.animatePosition(view: textView, from: CGPoint(x: -71.0 * direction, y: 0.0), to: .zero, additive: true)
transition.animateAlpha(view: textView, from: 0.0, to: 1.0)
}
transition.animateAlpha(view: self.avatarNode.view, from: 0.0, to: 1.0)
transition.animateScale(view: self.avatarNode.view, from: 0.01, to: 1.0)
}
func update(component: MessageItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
var transition = transition
if isFirstTime {
transition = .immediate
}
self.component = component
let theme = defaultDarkPresentationTheme
var fontSize: CGFloat = 14.0
let minimalHeight: CGFloat
let avatarSpacing: CGFloat
let avatarInset: CGFloat
let avatarSize: CGSize
let rightInset: CGFloat
var maximumNumberOfLines: Int = 0
var hasBackground = true
var textVerticalMargin: CGFloat = 15.0
switch component.style {
case .generic:
minimalHeight = 36.0
avatarSpacing = 10.0
avatarInset = 4.0
avatarSize = CGSize(width: 28.0, height: 28.0)
rightInset = 13.0
case .notification:
minimalHeight = 50.0
avatarSpacing = 10.0
avatarInset = 10.0
avatarSize = CGSize(width: 30.0, height: 30.0)
rightInset = 15.0
case .status:
minimalHeight = 24.0
avatarSpacing = 4.0
avatarInset = 4.0
avatarSize = CGSize(width: 16.0, height: 16.0)
rightInset = 0.0
maximumNumberOfLines = 1
hasBackground = false
fontSize = 13.0
textVerticalMargin = 0.0
}
let textFont = Font.regular(fontSize)
let boldTextFont = Font.semibold(fontSize)
let italicFont = Font.italic(fontSize)
let boldItalicTextFont = Font.semiboldItalic(fontSize)
let monospaceFont = Font.monospace(fontSize)
let textColor: UIColor = .white
let linkColor: UIColor = component.style == .status ? textColor : UIColor(rgb: 0x59b6fa)
let cornerRadius = minimalHeight * 0.5
let iconSpacing: CGFloat = 10.0
var peerName = ""
if case let .peer(peer) = component.icon {
if case .notification = component.style {
} else {
peerName = peer.compactDisplayTitle
if peerName.count > 40 {
peerName = "\(peerName.prefix(40))"
}
}
}
let text = component.text
var entities = component.entities
if let cachedEntities = self.cachedEntities {
entities = cachedEntities
} else if let availableReactions = component.availableReactions, text.count == 1 {
let emoji = component.text.strippedEmoji
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(emoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if case .builtin = reactionItem?.reaction.rawValue, let item = component.context.animatedEmojiStickersValue[emoji]?.first {
self.entityFiles[item.file.fileId] = item.file._parse()
entities.insert(MessageTextEntity(range: 0 ..< (text as NSString).length, type: .CustomEmoji(stickerPack: nil, fileId: item.file.fileId.id)), at: 0)
self.cachedEntities = entities
}
} else {
entities = entities.filter { entity in
switch entity.type {
case .Bold, .Italic, .Strikethrough, .Underline, .Spoiler:
return true
case .CustomEmoji:
if case let .peer(peer) = component.icon, peer.isPremium {
return true
}
return false
default:
return false
}
}
self.cachedEntities = entities
}
let attributedText: NSAttributedString
if case .notification = component.style {
attributedText = parseMarkdownIntoAttributedString(
text,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: linkColor),
linkAttribute: { _ in return nil }
)
)
} else {
let textWithAppliedEntities = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: linkColor, baseFont: textFont, linkFont: textFont, boldFont: boldTextFont, italicFont: italicFont, boldItalicFont: boldItalicTextFont, fixedFont: monospaceFont, blockQuoteFont: textFont, message: nil, entityFiles: self.entityFiles).mutableCopy() as! NSMutableAttributedString
if !peerName.isEmpty {
textWithAppliedEntities.insert(NSAttributedString(string: "\u{2066}\(peerName)\u{2069} ", font: boldTextFont, textColor: textColor), at: 0)
}
attributedText = textWithAppliedEntities
}
let spacing: CGFloat
switch component.icon {
case .peer:
spacing = avatarSpacing
case .icon:
spacing = iconSpacing
case .animation:
spacing = iconSpacing
}
let textSize = self.text.update(
transition: transition,
component: AnyComponent(MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff, alpha: 0.3),
text: .plain(attributedText),
maximumNumberOfLines: maximumNumberOfLines,
lineSpacing: 0.1,
spoilerColor: .white,
handleSpoilers: true
)),
environment: {},
containerSize: CGSize(width: availableSize.width - avatarInset - avatarSize.width - spacing - rightInset, height: .greatestFiniteMagnitude)
)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let size = CGSize(
width: avatarInset + avatarSize.width + spacing + textSize.width + rightInset,
height: max(minimalHeight, textSize.height + textVerticalMargin)
)
switch component.icon {
case let .peer(peer):
if peer.smallProfileImage != nil {
self.avatarNode.setPeerV2(
context: component.context,
theme: theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
} else {
self.avatarNode.setPeer(
context: component.context,
theme: theme,
peer: peer,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
}
let avatarFrame = CGRect(origin: CGPoint(x: avatarInset, y: avatarInset), size: avatarSize)
if self.avatarNode.bounds.isEmpty {
self.avatarNode.frame = mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL)
} else {
transition.setFrame(view: self.avatarNode.view, frame: mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = false
case let .icon(iconName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(name: iconName, tintColor: .white)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.container.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
case let .animation(animationName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: animationName
),
placeholderColor: nil,
startingPosition: .end,
size: CGSize(width: 40.0, height: 40.0),
loop: false
)),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset - 3.0,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.container.addSubview(iconView)
iconView.playOnce()
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
}
func mappedFrame(_ frame: CGRect, containerSize: CGSize, hasRTL: Bool) -> CGRect {
return CGRect(
origin: CGPoint(x: hasRTL ? containerSize.width - frame.minX - frame.width : frame.minX, y: frame.origin.y),
size: frame.size
)
}
let textFrame = CGRect(
origin: CGPoint(
x: avatarInset + avatarSize.width + spacing,
y: floorToScreenPixels((size.height - textSize.height) / 2.0)
),
size: textSize
)
if let textView = self.text.view {
if textView.superview == nil {
self.container.addSubview(textView)
}
transition.setFrame(view: textView, frame: mappedFrame(textFrame, containerSize: size, hasRTL: hasRTL))
}
transition.setFrame(view: self.container, frame: CGRect(origin: CGPoint(), size: size))
self.background.isHidden = !hasBackground
self.background.update(size: size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom(style: .default, color: glassColor)), transition: transition)
transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size))
if isFirstTime, let availableReactions = component.availableReactions, let textView = self.text.view {
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(component.text.strippedEmoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if let reactionItem {
Queue.mainQueue().justDispatch {
guard let listView = self.superview else {
return
}
let emojiTargetView = UIView(frame: CGRect(origin: CGPoint(x: textView.frame.width - 32.0, y: -17.0), size: CGSize(width: 44.0, height: 44.0)))
emojiTargetView.isUserInteractionEnabled = false
textView.addSubview(emojiTargetView)
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: nil, useDirectRendering: false)
self.container.addSubview(standaloneReactionAnimation.view)
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = listView.bounds
standaloneReactionAnimation.animateReactionSelection(
context: component.context,
theme: theme,
animationCache: component.context.animationCache,
reaction: reactionItem,
avatarPeers: [],
playHaptic: false,
isLarge: false,
hideCenterAnimation: true,
targetView: emojiTargetView,
addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = self.bounds
listView.addSubview(standaloneReactionAnimation.view)
},
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
}
}
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}