Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatRankInfoScreen/Sources/ChatRankInfoScreen.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

627 lines
27 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import AccountContext
import TelegramPresentationData
import PresentationDataUtils
import ComponentFlow
import ViewControllerComponent
import SheetComponent
import MultilineTextComponent
import BalancedTextComponent
import BundleIconComponent
import Markdown
import TextFormat
import TelegramStringFormatting
import GlassBarButtonComponent
import ButtonComponent
import LottieComponent
import RankChatPreviewItem
import ListSectionComponent
import ListItemComponentAdaptor
private final class ChatRankInfoSheetContent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let chatPeer: EnginePeer
let userPeer: EnginePeer
let role: ChatRankInfoScreenRole
let rank: String
let canChange: Bool
let getController: () -> ViewController?
let dismiss: () -> Void
init(
context: AccountContext,
chatPeer: EnginePeer,
userPeer: EnginePeer,
role: ChatRankInfoScreenRole,
rank: String,
canChange: Bool,
getController: @escaping () -> ViewController?,
dismiss: @escaping () -> Void
) {
self.context = context
self.chatPeer = chatPeer
self.userPeer = userPeer
self.role = role
self.rank = rank
self.canChange = canChange
self.getController = getController
self.dismiss = dismiss
}
static func ==(lhs: ChatRankInfoSheetContent, rhs: ChatRankInfoSheetContent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
final class State: ComponentState {
private let context: AccountContext
fileprivate let playButtonAnimation = ActionSlot<Void>()
private var didPlayAnimation = false
init(
context: AccountContext
) {
self.context = context
super.init()
}
func playAnimationIfNeeded() {
if !self.didPlayAnimation {
self.didPlayAnimation = true
self.playButtonAnimation.invoke(Void())
}
}
}
func makeState() -> State {
return State(context: self.context)
}
static var body: Body {
let closeButton = Child(GlassBarButtonComponent.self)
let icon = Child(ZStack<Empty>.self)
let title = Child(BalancedTextComponent.self)
let text = Child(BalancedTextComponent.self)
let memberPreview = Child(ListSectionComponent.self)
let adminPreview = Child(ListSectionComponent.self)
let additionalText = Child(BalancedTextComponent.self)
let button = Child(ButtonComponent.self)
return { context in
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
let state = context.state
let component = context.component
let controller = environment.controller
let theme = environment.theme
let strings = environment.strings
let textSideInset: CGFloat = 30.0 + environment.safeInsets.left
let titleFont = Font.bold(24.0)
let textFont = Font.regular(15.0)
let boldTextFont = Font.semibold(15.0)
let additionalTextFont = Font.regular(13.0)
let textColor = theme.actionSheet.primaryTextColor
let secondaryTextColor = theme.actionSheet.secondaryTextColor
let iconColor: UIColor
let titleString: String
let textString: String
let linkColor: UIColor
var linkHasBackground = true
let additionalTextString: String? = component.canChange ? nil : strings.RankInfo_ChangeInfo
var adminPreviewTag = strings.RankInfo_AdminTag
var adminPreviewRole: ChatRankInfoScreenRole = .admin
let userName = context.component.userPeer.compactDisplayTitle
let chatName = context.component.chatPeer.compactDisplayTitle
let canEdit = component.canChange
switch context.component.role {
case .creator:
var rank = !context.component.rank.isEmpty ? context.component.rank : strings.Conversation_Owner
rank = rank.replacingOccurrences(of: " ", with: "\u{00A0}")
iconColor = UIColor(rgb: 0x956ac8)
linkColor = iconColor
titleString = strings.RankInfo_Owner_Title
textString = strings.RankInfo_Owner_Text(" [\(rank)]() ", userName, userName, chatName).string
adminPreviewTag = strings.RankInfo_OwnerTag
adminPreviewRole = .creator
case .admin:
var rank = !context.component.rank.isEmpty ? context.component.rank : strings.Conversation_Admin
rank = rank.replacingOccurrences(of: " ", with: "\u{00A0}")
iconColor = UIColor(rgb: 0x49a355)
linkColor = iconColor
titleString = strings.RankInfo_Admin_Title
textString = strings.RankInfo_Admin_Text(" [\(rank)]() ", userName, userName, chatName).string
case .member:
let rank = context.component.rank.replacingOccurrences(of: " ", with: "\u{00A0}")
iconColor = secondaryTextColor.withMultipliedAlpha(0.85)
linkColor = secondaryTextColor
linkHasBackground = false
titleString = strings.RankInfo_Member_Title
textString = strings.RankInfo_Member_Text(" [\(rank)]() ", userName, userName, chatName).string
}
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
if linkHasBackground {
return ("TelegramBackground", NSUnderlineStyle.single.rawValue)
} else {
return nil
}
})
var contentSize = CGSize(width: context.availableSize.width, height: 40.0)
let icon = icon.update(
component: ZStack([
AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(RoundedRectangle(color: iconColor, cornerRadius: 50.0, size: CGSize(width: 100.0, height: 100.0)))
),
AnyComponentWithIdentity(
id: AnyHashable(1),
component: AnyComponent(BundleIconComponent(
name: "Chat/RankIcon",
tintColor: .white
))
)
]),
availableSize: CGSize(width: 100.0, height: 100.0),
transition: .immediate
)
context.add(icon
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + icon.size.height / 2.0))
)
contentSize.height += icon.size.height
contentSize.height += 20.0
let title = title.update(
component: BalancedTextComponent(
text: .plain(NSAttributedString(string: titleString, font: titleFont, textColor: textColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.1
),
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
transition: .immediate
)
context.add(title
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + title.size.height / 2.0))
)
contentSize.height += title.size.height
contentSize.height += 12.0
let constrainedTextWidth = context.availableSize.width - 16.0 * 2.0
let text = text.update(
component: BalancedTextComponent(
text: .markdown(
text: textString,
attributes: markdownAttributes
),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2,
insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0)
),
availableSize: CGSize(width: constrainedTextWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(text
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + text.size.height / 2.0))
)
contentSize.height += text.size.height
contentSize.height += 27.0
let previewWidth = (context.availableSize.width - 16.0 * 2.0 - 8.0) / 2.0
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
let listItemParams = ListViewItemLayoutParams(width: previewWidth, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true)
let emptyUser = TelegramUser(id: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value(0)), accessHash: nil, firstName: "A", lastName: "", username: "", phone: "", photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
let memberMessageItem = RankChatPreviewItem.MessageItem(
peer: .user(emptyUser),
text: "Reinhardt, we need to find you some new tunes, mkay?",
entities: nil,
media: [],
rank: strings.RankInfo_MemberTag,
rankRole: .member
)
var memberPreviewSectionItems: [AnyComponentWithIdentity<Empty>] = []
memberPreviewSectionItems.append(
AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemComponentAdaptor(
itemGenerator: RankChatPreviewItem(
context: component.context,
theme: environment.theme,
componentTheme: theme,
strings: strings,
sectionId: 0,
fontSize: presentationData.chatFontSize,
chatBubbleCorners: presentationData.chatBubbleCorners,
wallpaper: presentationData.chatWallpaper,
dateTimeFormat: environment.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
messageItems: [memberMessageItem],
containerWidth: context.availableSize.width - 32.0,
hideAvatars: true,
verticalInset: 9.0,
maskSide: true
),
params: listItemParams
)))
)
let memberPreview = memberPreview.update(
component: ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: memberPreviewSectionItems
),
availableSize: CGSize(width: previewWidth, height: context.availableSize.height),
transition: context.transition
)
context.add(memberPreview
.position(CGPoint(x: 16.0 + memberPreview.size.width / 2.0, y: contentSize.height + memberPreview.size.height / 2.0))
)
let adminMessageItem = RankChatPreviewItem.MessageItem(
peer: .user(emptyUser),
text: "Reinhardt, we need to find you some new tunes, mkay?",
entities: nil,
media: [],
rank: adminPreviewTag,
rankRole: adminPreviewRole
)
var adminPreviewSectionItems: [AnyComponentWithIdentity<Empty>] = []
adminPreviewSectionItems.append(
AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemComponentAdaptor(
itemGenerator: RankChatPreviewItem(
context: component.context,
theme: environment.theme,
componentTheme: theme,
strings: strings,
sectionId: 0,
fontSize: presentationData.chatFontSize,
chatBubbleCorners: presentationData.chatBubbleCorners,
wallpaper: presentationData.chatWallpaper,
dateTimeFormat: environment.dateTimeFormat,
nameDisplayOrder: presentationData.nameDisplayOrder,
messageItems: [adminMessageItem],
containerWidth: context.availableSize.width - 32.0,
hideAvatars: true,
verticalInset: 9.0,
maskSide: true
),
params: listItemParams
)))
)
let adminPreview = adminPreview.update(
component: ListSectionComponent(
theme: theme,
style: .glass,
header: nil,
footer: nil,
items: adminPreviewSectionItems
),
availableSize: CGSize(width: previewWidth, height: context.availableSize.height),
transition: context.transition
)
context.add(adminPreview
.position(CGPoint(x: context.availableSize.width - 16.0 - adminPreview.size.width / 2.0, y: contentSize.height + adminPreview.size.height / 2.0))
)
contentSize.height += adminPreview.size.height
contentSize.height += 27.0
if let additionalTextString {
let additionalText = additionalText.update(
component: BalancedTextComponent(
text: .plain(NSAttributedString(string: additionalTextString, font: additionalTextFont, textColor: secondaryTextColor)),
horizontalAlignment: .center,
maximumNumberOfLines: 0,
lineSpacing: 0.2
),
availableSize: CGSize(width: constrainedTextWidth, height: context.availableSize.height),
transition: .immediate
)
context.add(additionalText
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + additionalText.size.height / 2.0))
)
contentSize.height += additionalText.size.height
contentSize.height += 26.0
}
let closeButton = closeButton.update(
component: GlassBarButtonComponent(
size: CGSize(width: 44.0, height: 44.0),
backgroundColor: nil,
isDark: theme.overallDarkAppearance,
state: .glass,
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
BundleIconComponent(
name: "Navigation/Close",
tintColor: theme.chat.inputPanel.panelControlColor
)
)),
action: { _ in
component.dismiss()
}
),
availableSize: CGSize(width: 44.0, height: 44.0),
transition: .immediate
)
context.add(closeButton
.position(CGPoint(x: 16.0 + closeButton.size.width / 2.0, y: 16.0 + closeButton.size.height / 2.0))
)
var buttonTitle: [AnyComponentWithIdentity<Empty>] = []
if canEdit {
buttonTitle.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(ButtonTextContentComponent(
text: strings.RankInfo_SetMyTag,
badge: 0,
textColor: theme.list.itemCheckColors.foregroundColor,
badgeBackground: theme.list.itemCheckColors.foregroundColor,
badgeForeground: theme.list.itemCheckColors.fillColor
))))
} else {
buttonTitle.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(name: "anim_ok"),
color: theme.list.itemCheckColors.foregroundColor,
startingPosition: .begin,
size: CGSize(width: 28.0, height: 28.0),
playOnce: state.playButtonAnimation
))))
buttonTitle.append(AnyComponentWithIdentity(id: 1, component: AnyComponent(ButtonTextContentComponent(
text: strings.CocoonInfo_Understood,
badge: 0,
textColor: theme.list.itemCheckColors.foregroundColor,
badgeBackground: theme.list.itemCheckColors.foregroundColor,
badgeForeground: theme.list.itemCheckColors.fillColor
))))
}
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
let button = button.update(
component: ButtonComponent(
background: ButtonComponent.Background(
style: .glass,
color: theme.list.itemCheckColors.fillColor,
foreground: theme.list.itemCheckColors.foregroundColor,
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
),
content: AnyComponentWithIdentity(
id: AnyHashable(0),
component: AnyComponent(HStack(buttonTitle, spacing: 2.0))
),
isEnabled: true,
displaysProgress: false,
action: {
component.dismiss()
if canEdit, let controller = controller() as? ChatRankInfoScreen {
controller.completion?()
}
}
),
availableSize: CGSize(width: context.availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0),
transition: .immediate
)
context.add(button
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
)
contentSize.height += button.size.height
contentSize.height += buttonInsets.bottom
state.playAnimationIfNeeded()
return contentSize
}
}
}
final class ChatRankInfoSheetComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let chatPeer: EnginePeer
let userPeer: EnginePeer
let role: ChatRankInfoScreenRole
let rank: String
let canChange: Bool
init(
context: AccountContext,
chatPeer: EnginePeer,
userPeer: EnginePeer,
role: ChatRankInfoScreenRole,
rank: String,
canChange: Bool
) {
self.context = context
self.chatPeer = chatPeer
self.userPeer = userPeer
self.role = role
self.rank = rank
self.canChange = canChange
}
static func ==(lhs: ChatRankInfoSheetComponent, rhs: ChatRankInfoSheetComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
return true
}
static var body: Body {
let sheet = Child(SheetComponent<EnvironmentType>.self)
let animateOut = StoredActionSlot(Action<Void>.self)
let sheetExternalState = SheetComponent<EnvironmentType>.ExternalState()
return { context in
let environment = context.environment[EnvironmentType.self]
let controller = environment.controller
let dismiss: (Bool) -> Void = { animated in
if animated {
if let controller = controller() as? ChatRankInfoScreen {
animateOut.invoke(Action { _ in
controller.dismiss(completion: nil)
})
}
} else {
if let controller = controller() as? ChatRankInfoScreen {
controller.dismiss(completion: nil)
}
}
}
let sheet = sheet.update(
component: SheetComponent<EnvironmentType>(
content: AnyComponent<EnvironmentType>(ChatRankInfoSheetContent(
context: context.component.context,
chatPeer: context.component.chatPeer,
userPeer: context.component.userPeer,
role: context.component.role,
rank: context.component.rank,
canChange: context.component.canChange,
getController: controller,
dismiss: {
dismiss(true)
}
)),
style: .glass,
backgroundColor: .color(environment.theme.actionSheet.opaqueItemBackgroundColor),
followContentSizeChanges: true,
clipsContent: true,
autoAnimateOut: false,
externalState: sheetExternalState,
animateOut: animateOut,
onPan: {},
willDismiss: {}
),
environment: {
environment
SheetComponentEnvironment(
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
isDisplaying: environment.value.isVisible,
isCentered: environment.metrics.widthClass == .regular,
hasInputHeight: !environment.inputHeight.isZero,
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
dismiss: { animated in
dismiss(animated)
}
)
},
availableSize: context.availableSize,
transition: context.transition
)
context.add(sheet
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
)
if let controller = controller(), !controller.automaticallyControlPresentationContextLayout {
var sideInset: CGFloat = 0.0
var bottomInset: CGFloat = max(environment.safeInsets.bottom, sheetExternalState.contentHeight)
if case .regular = environment.metrics.widthClass {
sideInset = floor((context.availableSize.width - 430.0) / 2.0) - 12.0
bottomInset = (context.availableSize.height - sheetExternalState.contentHeight) / 2.0 + sheetExternalState.contentHeight
}
let layout = ContainerViewLayout(
size: context.availableSize,
metrics: environment.metrics,
deviceMetrics: environment.deviceMetrics,
intrinsicInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: bottomInset, right: 0.0),
safeInsets: UIEdgeInsets(top: 0.0, left: max(sideInset, environment.safeInsets.left), bottom: 0.0, right: max(sideInset, environment.safeInsets.right)),
additionalInsets: .zero,
statusBarHeight: environment.statusBarHeight,
inputHeight: nil,
inputHeightIsInteractivellyChanging: false,
inVoiceOver: false
)
controller.presentationContext.containerLayoutUpdated(layout, transition: context.transition.containedViewLayoutTransition)
}
return context.availableSize
}
}
}
public final class ChatRankInfoScreen: ViewControllerComponentContainer {
private let context: AccountContext
fileprivate let completion: (() -> Void)?
public init(
context: AccountContext,
chatPeer: EnginePeer,
userPeer: EnginePeer,
role: ChatRankInfoScreenRole,
rank: String,
canChange: Bool,
completion: (() -> Void)?
) {
self.context = context
self.completion = completion
super.init(
context: context,
component: ChatRankInfoSheetComponent(
context: context,
chatPeer: chatPeer,
userPeer: userPeer,
role: role,
rank: rank,
canChange: canChange
),
navigationBarAppearance: .none,
statusBarStyle: .ignore,
theme: .default
)
self.navigationPresentation = .flatModal
self.automaticallyControlPresentationContextLayout = false
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
public override func viewDidLoad() {
super.viewDidLoad()
self.view.disablesInteractiveModalDismiss = true
}
public func dismissAnimated() {
if let view = self.node.hostView.findTaggedView(tag: SheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? SheetComponent<ViewControllerComponentContainer.Environment>.View {
view.dismissAnimated()
}
}
}