From 4504afcde363cc16fee8d62ac1df75eb8675b1c2 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Thu, 9 Apr 2026 12:32:20 +0200 Subject: [PATCH 01/69] Add design spec for tgcalls CLI UDP reflector mode Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-09-reflector-support-design.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-09-reflector-support-design.md diff --git a/docs/superpowers/specs/2026-04-09-reflector-support-design.md b/docs/superpowers/specs/2026-04-09-reflector-support-design.md new file mode 100644 index 0000000000..e66959982d --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-reflector-support-design.md @@ -0,0 +1,63 @@ +# tgcalls CLI: UDP Reflector Mode + +## Overview + +Add a `--mode reflector` option to the tgcalls CLI test tool, routing both call instances through a real Telegram UDP reflector instead of direct P2P loopback. + +## CLI Interface + +- `--mode p2p` — current behavior (direct P2P loopback, no servers) +- `--mode reflector` — route through a real Telegram UDP reflector +- `--mode` is **required**. Exit with usage error if missing. +- `--reflector host:port` — specifies the reflector address. **Required** when `--mode reflector`. Error if missing in reflector mode or if provided with `--mode p2p`. +- `--duration` and `--quiet` are unchanged. + +## Reflector Configuration + +When `--mode reflector`: + +### Peer Tag Generation + +Generate 16 random bytes. Copy to make two tags: +- Caller tag: byte 0 = `0x00` +- Callee tag: byte 0 = `0x01` + +### RtcServer Setup + +Each instance gets one `RtcServer` entry with its respective peer tag: + +| Field | Value | +|------------|------------------------------------------------| +| `id` | `1` | +| `host` | from `--reflector` argument | +| `port` | from `--reflector` argument | +| `login` | `"reflector"` | +| `password` | hex-encoded 16-byte peer tag (differs by side) | +| `isTurn` | `true` | +| `isTcp` | `false` | + +### Descriptor Changes + +- `config.enableP2P = false` +- `rtcServers` populated with the single reflector server +- No `customParameters` changes (standalone reflector mode is not used) + +### P2P Mode + +Unchanged from current behavior: `enableP2P = true`, empty `rtcServers`. + +## Summary Output + +Add a mode line to the call summary: + +``` +Mode: reflector (91.108.13.2:596) +``` + +or: + +``` +Mode: p2p +``` + +No other output changes. The existing audio validation (440Hz sine, non-silence detection) remains the success criterion for both modes. From 73d25e7be31fcc672a6c70bb6bc002cd11a4f388 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 28 Apr 2026 14:31:17 +0200 Subject: [PATCH 02/69] Various improvements --- .../Telegram-iOS/en.lproj/Localizable.strings | 3 +- .../Sources/ChatController.swift | 1 + .../Sources/Node/ChatListItem.swift | 12 ++- .../ChatPresentationInterfaceState.swift | 19 +++++ .../Messages/DeleteMessages.swift | 77 +++++++++++++++++- .../Sources/Utils/CanSendMessagesToPeer.swift | 21 +++++ .../Resources/PresentationResourceKey.swift | 1 + .../PresentationResourcesChatList.swift | 6 ++ .../Sources/TopMessageReactions.swift | 10 ++- .../Sources/PaneSearchContainerNode.swift | 57 ++++++++++++- .../StickerPaneSearchContentNode.swift | 69 ++++++++-------- .../Sources/PeerInfoInteraction.swift | 3 + .../Sources/PeerInfoProfileItems.swift | 31 +++---- .../Sources/PeerInfoScreen.swift | 33 +++++++- .../Sources/PeerInfoScreenOpenChat.swift | 44 ++++++++++ .../Sources/PeerInfoSettingsItems.swift | 23 +++--- .../Sources/ChatbotSetupScreen.swift | 6 +- .../VoiceMessageIcon.imageset/Contents.json | 12 +++ .../voice_message_20.pdf | Bin 0 -> 5546 bytes ...ChatControllerOpenMessageContextMenu.swift | 19 ++++- .../TelegramUI/Sources/ChatController.swift | 13 ++- .../Sources/ChatControllerAdminBanUsers.swift | 24 ++++++ ...rollerOpenMessageReactionContextMenu.swift | 32 +++----- .../ChatInterfaceStateContextMenus.swift | 30 +------ .../Sources/NavigateToChatController.swift | 1 + 25 files changed, 421 insertions(+), 126 deletions(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/voice_message_20.pdf diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 73f827f844..a2303f8781 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -2067,6 +2067,7 @@ "StickerPack.Share" = "Share"; "StickerPack.Send" = "Send Sticker"; "StickerPack.AddSticker" = "Add Sticker"; +"StickerPack.RemoveStickerSet" = "Remove Sticker Set"; "StickerPack.RemoveStickerCount_1" = "Remove 1 Sticker"; "StickerPack.RemoveStickerCount_2" = "Remove 2 Stickers"; @@ -16214,5 +16215,5 @@ Error: %8$@"; "Chat.AdminAction.ToastReactionsDeletedTitleSingle" = "Reaction Deleted"; "Chat.AdminAction.ToastReactionsDeletedTextSingle" = "Reaction Deleted."; -"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Messages Deleted."; +"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted."; "Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted."; diff --git a/submodules/AccountContext/Sources/ChatController.swift b/submodules/AccountContext/Sources/ChatController.swift index 7b1781e32a..496e3c3b11 100644 --- a/submodules/AccountContext/Sources/ChatController.swift +++ b/submodules/AccountContext/Sources/ChatController.swift @@ -1117,6 +1117,7 @@ public protocol ChatController: ViewController { func activateSearch(domain: ChatSearchDomain, query: String) func activateInput(type: ChatControllerActivateInput) func beginClearHistory(type: InteractiveHistoryClearingType) + func presentReactionDeletionOptions(author: Peer, messageId: MessageId) func performScrollToTop() -> Bool func transferScrollingVelocity(_ velocity: CGFloat) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 43de3828f5..2e76203112 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -2578,6 +2578,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case poll case todo case game + case voiceMessage } var messageTypeIcon: MessageTypeIcon? var ignoreForwardedIcon = false @@ -2895,7 +2896,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { messageTypeIcon = .story } else { for media in message.media { - if let _ = media as? TelegramMediaPoll { + if let file = media as? TelegramMediaFile { + if file.isVoice { + messageTypeIcon = .voiceMessage + } + } else if let _ = media as? TelegramMediaPoll { messageTypeIcon = .poll } else if let _ = media as? TelegramMediaTodo { messageTypeIcon = .todo @@ -3106,6 +3111,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case .game: currentMessageTypeIcon = PresentationResourcesChatList.gameIcon(item.presentationData.theme) currentMessageTypeIconOffset.y = -1.0 + case .voiceMessage: + currentMessageTypeIcon = PresentationResourcesChatList.voiceMessageIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -1.0 default: break } @@ -3115,7 +3123,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if !contentImageSpecs.isEmpty { textLeftCutout += forwardedIconSpacing } else { - textLeftCutout += contentImageTrailingSpace + textLeftCutout += contentImageTrailingSpace - 1.0 } } diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift index 2ae5988bd0..c705171903 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift @@ -1591,3 +1591,22 @@ public func canSendMessagesToChat(_ state: ChatPresentationInterfaceState) -> Bo return false } } + +public func canSendReactionsToChat(_ state: ChatPresentationInterfaceState) -> Bool { + if let peer = state.renderedPeer?.peer { + let canBypassRestrictions = canBypassRestrictions(chatPresentationInterfaceState: state) + return canSendReactionsToPeer(EnginePeer(peer), ignoreDefault: canBypassRestrictions) + } else if case .customChatContents = state.chatLocation { + if case let .customChatContents(contents) = state.subject { + if case .hashTagSearch = contents.kind { + return false + } else { + return true + } + } else { + return true + } + } else { + return false + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift index 3d37e36ab1..5c3f072d76 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift @@ -94,7 +94,82 @@ func _internal_deleteAllReactionsWithAuthor(account: Account, peerId: PeerId, au func _internal_deleteReaction(account: Account, messageId: MessageId, authorId: PeerId) -> Signal { return account.postbox.transaction { transaction -> (Peer?, Peer?) in - return (transaction.getPeer(messageId.peerId), transaction.getPeer(authorId)) + let peer = transaction.getPeer(messageId.peerId) + let author = transaction.getPeer(authorId) + + if peer.flatMap(apiInputPeer) != nil && author.flatMap(apiInputPeer) != nil { + transaction.updateMessage(messageId, update: { currentMessage in + var attributes = currentMessage.attributes + var updated = false + + for i in 0 ..< attributes.count { + guard let attribute = attributes[i] as? ReactionsMessageAttribute else { + continue + } + + let removedRecentPeers = attribute.recentPeers.filter { $0.peerId == authorId } + var updatedTopPeers = attribute.topPeers + var removedStarsTopPeerCount: Int32 = 0 + for j in (0 ..< updatedTopPeers.count).reversed() { + if updatedTopPeers[j].peerId == authorId { + removedStarsTopPeerCount += updatedTopPeers[j].count + updatedTopPeers.remove(at: j) + } + } + + if removedRecentPeers.isEmpty && removedStarsTopPeerCount == 0 { + continue + } + + var updatedReactions = attribute.reactions + for removedRecentPeer in removedRecentPeers { + if let index = updatedReactions.firstIndex(where: { $0.value == removedRecentPeer.value }) { + if removedRecentPeer.value != .stars || removedStarsTopPeerCount == 0 { + updatedReactions[index].count -= 1 + } + if removedRecentPeer.isMy { + updatedReactions[index].chosenOrder = nil + } + } + } + if removedStarsTopPeerCount != 0, let index = updatedReactions.firstIndex(where: { $0.value == .stars }) { + updatedReactions[index].count -= removedStarsTopPeerCount + } + for j in (0 ..< updatedReactions.count).reversed() { + if updatedReactions[j].count <= 0 { + updatedReactions.remove(at: j) + } + } + + let updatedRecentPeers = attribute.recentPeers.filter { $0.peerId != authorId } + let updatedAttribute = ReactionsMessageAttribute(canViewList: attribute.canViewList, isTags: attribute.isTags, reactions: updatedReactions, recentPeers: updatedRecentPeers, topPeers: updatedTopPeers) + if updatedAttribute != attribute { + attributes[i] = updatedAttribute + updated = true + } + } + + if !updated { + return .skip + } + + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + + var tags = currentMessage.tags + if attributes.contains(where: { ($0 as? ReactionsMessageAttribute)?.hasUnseen == true }) { + tags.insert(.unseenReaction) + } else { + tags.remove(.unseenReaction) + } + + return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + + return (peer, author) } |> mapToSignal { peer, author in guard let inputPeer = peer.flatMap(apiInputPeer), let inputAuthor = author.flatMap(apiInputPeer) else { diff --git a/submodules/TelegramCore/Sources/Utils/CanSendMessagesToPeer.swift b/submodules/TelegramCore/Sources/Utils/CanSendMessagesToPeer.swift index 00e74c43ae..d1684d3f0b 100644 --- a/submodules/TelegramCore/Sources/Utils/CanSendMessagesToPeer.swift +++ b/submodules/TelegramCore/Sources/Utils/CanSendMessagesToPeer.swift @@ -19,3 +19,24 @@ public func canSendMessagesToPeer(_ peer: EnginePeer, ignoreDefault: Bool = fals return channel.hasPermission(.sendSomething, ignoreDefault: ignoreDefault) } } + +public func canSendReactionsToPeer(_ peer: EnginePeer, ignoreDefault: Bool = false) -> Bool { + switch peer { + case .user: + return !peer.isDeleted + case let .legacyGroup(group): + switch group.role { + case .creator, .admin: + return !peer.isDeleted + case .member: + if let defaultBannedRights = group.defaultBannedRights, defaultBannedRights.flags.contains(.banSendReactions) && !ignoreDefault { + return false + } + return !peer.isDeleted + } + case let .secretChat(secretChat): + return secretChat.embeddedState == .active + case let .channel(channel): + return channel.hasBannedPermission(.banSendReactions, ignoreDefault: ignoreDefault) == nil + } +} diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift index b1f6611745..ff32a5575d 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift @@ -138,6 +138,7 @@ public enum PresentationResourceKey: Int32 { case chatListCallOutgoingIcon case chatListCallVideoIncomingIcon case chatListCallVideoOutgoingIcon + case chatListVoiceMessageIcon case chatListGeneralTopicIcon case chatListGeneralTopicTemplateIcon diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift index 2092f8b75b..0994b46a48 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift @@ -349,6 +349,12 @@ public struct PresentationResourcesChatList { }) } + public static func voiceMessageIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListVoiceMessageIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/VoiceMessageIcon"), color: theme.chatList.muteIconColor) + }) + } + public static func verifiedIcon(_ theme: PresentationTheme) -> UIImage? { return theme.image(PresentationResourceKey.chatListVerifiedIcon.rawValue, { theme in if let backgroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconBackground"), let foregroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconForeground") { diff --git a/submodules/TelegramUI/Components/Chat/TopMessageReactions/Sources/TopMessageReactions.swift b/submodules/TelegramUI/Components/Chat/TopMessageReactions/Sources/TopMessageReactions.swift index 0f549ecfeb..bb3f83e615 100644 --- a/submodules/TelegramUI/Components/Chat/TopMessageReactions/Sources/TopMessageReactions.swift +++ b/submodules/TelegramUI/Components/Chat/TopMessageReactions/Sources/TopMessageReactions.swift @@ -10,7 +10,7 @@ public enum AllowedReactions { case all } -public func peerMessageAllowedReactions(context: AccountContext, message: Message) -> Signal<(allowedReactions: AllowedReactions?, areStarsEnabled: Bool), NoError> { +public func peerMessageAllowedReactions(context: AccountContext, message: Message, ignoreDefault: Bool = false) -> Signal<(allowedReactions: AllowedReactions?, areStarsEnabled: Bool), NoError> { if message.id.peerId == context.account.peerId { return .single((.all, false)) } @@ -41,6 +41,10 @@ public func peerMessageAllowedReactions(context: AccountContext, message: Messag areStarsEnabled = value } + if let peer, !canSendReactionsToPeer(peer, ignoreDefault: ignoreDefault) { + return (nil, areStarsEnabled) + } + if let effectiveReactions = message.effectiveReactions(isTags: message.areReactionsTags(accountPeerId: context.account.peerId)), effectiveReactions.count >= maxReactionCount { return (.set(Set(effectiveReactions.map(\.value))), areStarsEnabled) } @@ -256,7 +260,7 @@ public func tagMessageReactions(context: AccountContext, subPeerId: EnginePeer.I } } -public func topMessageReactions(context: AccountContext, message: Message, subPeerId: EnginePeer.Id?) -> Signal<[ReactionItem], NoError> { +public func topMessageReactions(context: AccountContext, message: Message, subPeerId: EnginePeer.Id?, ignoreDefault: Bool = false) -> Signal<[ReactionItem], NoError> { if message.id.peerId == context.account.peerId { var loadTags = false if let effectiveReactionsAttribute = message.effectiveReactionsAttribute(isTags: message.areReactionsTags(accountPeerId: context.account.peerId)) { @@ -286,7 +290,7 @@ public func topMessageReactions(context: AccountContext, message: Message, subPe } } - let allowedReactionsWithFiles: Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> = peerMessageAllowedReactions(context: context, message: message) + let allowedReactionsWithFiles: Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> = peerMessageAllowedReactions(context: context, message: message, ignoreDefault: ignoreDefault) |> mapToSignal { allowedReactions, areStarsEnabled -> Signal<(reactions: AllowedReactions, files: [Int64: TelegramMediaFile], areStarsEnabled: Bool)?, NoError> in guard let allowedReactions = allowedReactions else { return .single(nil) diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift index 51f85faf3c..b9255fdffd 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift @@ -220,7 +220,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer return self.contentNode.itemAt(point: CGPoint(x: point.x, y: point.y - searchBarHeight)) } - private func openSelectedPackMoreMenu() { + private func openMore() { guard let selectedStickerPack = self.selectedStickerPack, let controlsView = self.navigationButtons.view as? GlassControlPanelComponent.View, let rightItemView = controlsView.rightItemView, let sourceView = rightItemView.itemView(id: AnyHashable("more")) else { return } @@ -272,6 +272,59 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer }), nil) }))) + if selectedStickerPack.installed { + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: strings.StickerPack_RemoveStickerSet, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + + let info = selectedStickerPack.info + let context = self.context + let _ = (context.engine.stickers.removeStickerPackInteractively(id: info.id, option: .delete) + |> deliverOnMainQueue).start(next: { [weak self] indexAndItems in + guard let self, let (positionInList, items) = indexAndItems else { + return + } + + let stickerItems = items.compactMap { $0 as? StickerPackItem } + if let contentNode = self.contentNode as? StickerPaneSearchContentNode { + contentNode.setPackInstalledState(id: info.id, installed: false) + } + + var animateInAsReplacement = false + if let navigationController = self.interaction.getNavigationController() { + for controller in navigationController.overlayControllers { + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitActionAndReplacementAnimation() + animateInAsReplacement = true + } + } + } + + let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) + let undoController = UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_RemovedTitle, text: presentationData.strings.StickerPackActionInfo_RemovedText(info.title).string, undo: true, info: info, topItem: stickerItems.first, context: context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { [weak self] action in + if case .undo = action { + let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: stickerItems, positionInList: positionInList).start() + if let contentNode = self?.contentNode as? StickerPaneSearchContentNode { + contentNode.setPackInstalledState(id: info.id, installed: true) + } + } + return true + }) + if let navigationController = self.interaction.getNavigationController() { + navigationController.presentOverlay(controller: undoController) + } else { + self.interaction.presentController(undoController, nil) + } + }) + }))) + } + let contextController = makeContextController( presentationData: presentationData, source: .reference(StickerPaneSearchHeaderContextReferenceContentSource(sourceView: sourceView)), @@ -327,7 +380,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer id: AnyHashable("more"), content: .animation("anim_morewide"), action: { [weak self] in - self?.openSelectedPackMoreMenu() + self?.openMore() } ) ], diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift index 0648188eac..bb5eeb9b62 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/StickerPaneSearchContentNode.swift @@ -82,6 +82,7 @@ private enum StickerSearchEntry: Identifiable, Comparable { struct StickerPaneSearchSelectedPack { let info: StickerPackCollectionInfo + let installed: Bool } private struct StickerPaneSearchPack: Equatable { @@ -497,19 +498,8 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { guard let strongSelf = self else { return } - - var animateInAsReplacement = false - if let navigationController = strongSelf.interaction.getNavigationController() { - for controller in navigationController.overlayControllers { - if let controller = controller as? UndoOverlayController { - controller.dismissWithCommitActionAndReplacementAnimation() - animateInAsReplacement = true - } - } - } - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: theme) - strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in + strongSelf.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: strongSelf.context), elevatedLayout: false, action: { _ in return true })) })) @@ -688,7 +678,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { strongSelf.currentSearchEntries = entries strongSelf.currentPacks = packItems if let selectedPack = strongSelf.selectedPack, let updatedPack = packItems.first(where: { $0.info.id == selectedPack.info.id }), selectedPack.installed != updatedPack.installed { - strongSelf.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: updatedPack.installed) + strongSelf.setPackInstalledState(id: selectedPack.info.id, installed: updatedPack.installed, transition: .immediate) } strongSelf.currentSearchIsFinal = final strongSelf.searchIsActive = true @@ -796,20 +786,41 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { return !selectedPack.installed && !self.installedPackIds.contains(selectedPack.info.id) } - private func markPackInstalled(_ id: ItemCollectionId) { - self.installedPackIds.insert(id) - - if let selectedPack = self.selectedPack, selectedPack.info.id == id, !selectedPack.installed { - self.selectedPack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: true) + func setPackInstalledState(id: ItemCollectionId, installed: Bool, transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut)) { + if installed { + self.installedPackIds.insert(id) + } else { + self.installedPackIds.remove(id) } + var updatedSelectedPack: StickerPaneSearchPack? + if let selectedPack = self.selectedPack, selectedPack.info.id == id { + if selectedPack.installed != installed { + let pack = StickerPaneSearchPack(info: selectedPack.info, topItems: selectedPack.topItems, installed: installed) + self.selectedPack = pack + updatedSelectedPack = pack + } else { + updatedSelectedPack = selectedPack + } + } + + var updatedPacks = false self.currentPacks = self.currentPacks.map { pack in - if pack.info.id == id && !pack.installed { - return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: true) + if pack.info.id == id && pack.installed != installed { + updatedPacks = true + return StickerPaneSearchPack(info: pack.info, topItems: pack.topItems, installed: installed) } else { return pack } } + + if let updatedSelectedPack { + self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: updatedSelectedPack.info, installed: updatedSelectedPack.installed)) + } + + if updatedSelectedPack != nil || updatedPacks { + self.requestLayout(transition: transition) + } } private func updatePackPanelExpansionFromScroll() { @@ -865,7 +876,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { self.notFoundNode.isHidden = true self.gridNode.isHidden = false self.trendingPane.isHidden = true - self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info)) + self.selectedPackUpdated?(StickerPaneSearchSelectedPack(info: pack.info, installed: pack.installed)) self.enqueueEntries(self.entries(packItems: pack.topItems), interaction: interaction, crossfade: true) self.isPackPanelExpanded = true @@ -896,8 +907,7 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { let packId = selectedPack.info.id let accessHash = selectedPack.info.accessHash - self.markPackInstalled(packId) - self.requestLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + self.setPackInstalledState(id: packId, installed: true) let installSignal = (context.engine.stickers.loadedStickerPack(reference: .id(id: packId.id, accessHash: accessHash), forceActualized: false) |> mapToSignal { result -> Signal<(StickerPackCollectionInfo, [StickerPackItem]), NoError> in @@ -931,19 +941,8 @@ final class StickerPaneSearchContentNode: ASDisplayNode, PaneSearchContentNode { guard let self else { return } - - var animateInAsReplacement = false - if let navigationController = self.interaction.getNavigationController() { - for controller in navigationController.overlayControllers { - if let controller = controller as? UndoOverlayController { - controller.dismissWithCommitActionAndReplacementAnimation() - animateInAsReplacement = true - } - } - } - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }.withUpdated(theme: self.theme) - self.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: self.context), elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: { _ in + self.interaction.getNavigationController()?.presentOverlay(controller: UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: self.context), elevatedLayout: false, action: { _ in return true })) })) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoInteraction.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoInteraction.swift index 0f2d18d73f..1798db6f7b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoInteraction.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoInteraction.swift @@ -23,6 +23,7 @@ final class PeerInfoInteraction { let openAddContact: () -> Void let updateBlocked: (Bool) -> Void let openReport: (PeerInfoReportType) -> Void + let openDeleteReaction: (MessageId) -> Void let openShareBot: () -> Void let openAddBotToGroup: () -> Void let performBotCommand: (PeerInfoBotCommand) -> Void @@ -101,6 +102,7 @@ final class PeerInfoInteraction { openAddContact: @escaping () -> Void, updateBlocked: @escaping (Bool) -> Void, openReport: @escaping (PeerInfoReportType) -> Void, + openDeleteReaction: @escaping (MessageId) -> Void, openShareBot: @escaping () -> Void, openAddBotToGroup: @escaping () -> Void, performBotCommand: @escaping (PeerInfoBotCommand) -> Void, @@ -178,6 +180,7 @@ final class PeerInfoInteraction { self.openAddContact = openAddContact self.updateBlocked = updateBlocked self.openReport = openReport + self.openDeleteReaction = openDeleteReaction self.openShareBot = openShareBot self.openAddBotToGroup = openAddBotToGroup self.performBotCommand = performBotCommand diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift index eeb1b21f0f..799802ed35 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift @@ -42,6 +42,7 @@ func infoItems( presentationData: PresentationData, interaction: PeerInfoInteraction, reactionSourceMessageId: MessageId?, + canDeleteReaction: Bool, callMessages: [Message], chatLocation: ChatLocation, isOpenedFromChat: Bool, @@ -91,9 +92,9 @@ func infoItems( let ItemAffiliateInfo = 4001 let ItemBusinessHours = 5000 let ItemLocation = 5001 - let ItemSendMessage = 6000 - let ItemReport = 6001 - let ItemAddToContacts = 6002 + let ItemAddToContacts = 6000 + let ItemDeleteReaction = 6001 + let ItemReport = 6002 let ItemBlock = 6003 let ItemEncryptionKey = 6004 let ItemBalanceHeader = 7000 @@ -369,23 +370,23 @@ func infoItems( } if !isMyProfile { - if let reactionSourceMessageId = reactionSourceMessageId, !data.isContact { - items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemSendMessage, text: presentationData.strings.UserInfo_SendMessage, action: { - interaction.openChat(nil) + if !data.isContact, user.botInfo == nil { + items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemAddToContacts, text: presentationData.strings.PeerInfo_AddToContacts, action: { + interaction.openAddContact() })) - + } + + if let reactionSourceMessageId = reactionSourceMessageId { + if canDeleteReaction { + //TODO:localize + items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemDeleteReaction, text: "Delete Reaction", color: .destructive, action: { + interaction.openDeleteReaction(reactionSourceMessageId) + })) + } items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemReport, text: presentationData.strings.ReportPeer_BanAndReport, color: .destructive, action: { interaction.openReport(.reaction(reactionSourceMessageId)) })) } else { - if !data.isContact { - if user.botInfo == nil { - items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemAddToContacts, text: presentationData.strings.PeerInfo_AddToContacts, action: { - interaction.openAddContact() - })) - } - } - var isBlocked = false if let cachedData = data.cachedData as? CachedUserData, cachedData.isBlocked { isBlocked = true diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index bb01e62bc9..d388044837 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -283,6 +283,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro var forceIsContactPromise = ValuePromise(false) let reactionSourceMessageId: MessageId? let sourceMessageId: MessageId? + var canDeleteReaction = false var dataDisposable: Disposable? let activeActionDisposable = MetaDisposable() @@ -460,6 +461,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro openReport: { [weak self] type in self?.openReport(type: type, contextController: nil, backAction: nil) }, + openDeleteReaction: { [weak self] messageId in + self?.openDeleteReaction(messageId: messageId) + }, openShareBot: { [weak self] in self?.openShareBot() }, @@ -2485,14 +2489,37 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self?.updateNavigation(transition: .immediate, additive: true, animateHeader: true) } + let reactionSourceMessage: Signal + if let reactionSourceMessageId = self.reactionSourceMessageId { + reactionSourceMessage = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: reactionSourceMessageId)) + } else { + reactionSourceMessage = .single(nil) + } + self.dataDisposable = combineLatest( queue: Queue.mainQueue(), screenData, - self.forceIsContactPromise.get() - ).startStrict(next: { [weak self] data, forceIsContact in + self.forceIsContactPromise.get(), + reactionSourceMessage + ).startStrict(next: { [weak self] data, forceIsContact, reactionSourceMessage in guard let strongSelf = self else { return } + + var canDeleteReaction = false + if let sourceMessage = reactionSourceMessage?._asMessage(), let reactionPeerId = data.peer?.id, let channel = sourceMessage.peers[sourceMessage.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) { + for attribute in sourceMessage.attributes { + guard let attribute = attribute as? ReactionsMessageAttribute else { + continue + } + if attribute.recentPeers.contains(where: { $0.peerId == reactionPeerId }) || attribute.topPeers.contains(where: { $0.peerId == reactionPeerId }) { + canDeleteReaction = true + break + } + } + } + strongSelf.canDeleteReaction = canDeleteReaction + if data.isContact && forceIsContact { strongSelf.forceIsContactPromise.set(false) } else { @@ -5414,7 +5441,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro insets.left += sectionInset insets.right += sectionInset - let items = self.isSettings ? settingsItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, isExpanded: self.headerNode.isAvatarExpanded) : infoItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, reactionSourceMessageId: self.reactionSourceMessageId, callMessages: self.callMessages, chatLocation: self.chatLocation, isOpenedFromChat: self.isOpenedFromChat, isMyProfile: self.isMyProfile) + let items = self.isSettings ? settingsItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, isExpanded: self.headerNode.isAvatarExpanded) : infoItems(data: self.data, context: self.context, presentationData: self.presentationData, interaction: self.interaction, reactionSourceMessageId: self.reactionSourceMessageId, canDeleteReaction: self.canDeleteReaction, callMessages: self.callMessages, chatLocation: self.chatLocation, isOpenedFromChat: self.isOpenedFromChat, isMyProfile: self.isMyProfile) contentHeight += headerHeight if !((self.isSettings || self.isMyProfile) && self.state.isEditing) { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift index b9fba293f5..35f61fa9ed 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift @@ -81,6 +81,50 @@ extension PeerInfoScreenNode { } } + func openDeleteReaction(messageId: MessageId) { + guard let authorPeer = self.data?.peer?._asPeer(), let navigationController = self.controller?.navigationController as? NavigationController else { + return + } + + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId), + TelegramEngine.EngineData.Item.Messages.Message(id: messageId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] sourcePeer, sourceMessage in + guard let self, let sourcePeer, let sourceMessage = sourceMessage?._asMessage() else { + return + } + guard let channel = sourceMessage.peers[sourceMessage.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) else { + return + } + + var hasReaction = false + for attribute in sourceMessage.attributes { + guard let attribute = attribute as? ReactionsMessageAttribute else { + continue + } + if attribute.recentPeers.contains(where: { $0.peerId == authorPeer.id }) || attribute.topPeers.contains(where: { $0.peerId == authorPeer.id }) { + hasReaction = true + break + } + } + guard hasReaction else { + return + } + + let chatLocation: NavigateToChatControllerParams.Location + if case let .channel(channel) = sourcePeer, channel.isForumOrMonoForum, let threadId = sourceMessage.threadId { + chatLocation = .replyThread(ChatReplyThreadMessage(peerId: sourcePeer.id, threadId: threadId, channelMessageId: nil, isChannelPost: false, isForumPost: true, isMonoforumPost: channel.isMonoForum, maxMessage: nil, maxReadIncomingMessageId: nil, maxReadOutgoingMessageId: nil, unreadCount: 0, initialFilledHoles: IndexSet(), initialAnchor: .automatic, isNotAvailable: false)) + } else { + chatLocation = .peer(sourcePeer) + } + + self.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: self.context, chatLocation: chatLocation, subject: .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false), keepStack: .always, useExisting: true, completion: { chatController in + chatController.presentReactionDeletionOptions(author: authorPeer, messageId: messageId) + })) + }) + } + func openChatWithClearedHistory(type: InteractiveHistoryClearingType) { guard let peer = self.data?.chatPeer, let navigationController = self.controller?.navigationController as? NavigationController else { return diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift index b2e43e5e9c..3bc0da8950 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift @@ -496,19 +496,18 @@ func settingsEditingItems(data: PeerInfoScreenData?, state: PeerInfoState, conte } } - if "".isEmpty { - //TODO:localize - let automationBotTitle: String - if let botPeer = data.businessConnectedBot { - automationBotTitle = "@\(botPeer.compactDisplayTitle)" - } else { - automationBotTitle = "Off" - } - items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), text: "Chat Automation", icon: PresentationResourcesSettings.aiTools, action: { - interaction.editingOpenBusinessChatBots() - })) - items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: "Add a bot to reply to messages on your behalf.")) + //TODO:localize + let automationBotTitle: String + if let botPeer = data.businessConnectedBot { + let _ = botPeer + automationBotTitle = "@\(botPeer.compactDisplayTitle)" + } else { + automationBotTitle = "Off" } + items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), additionalBadgeLabel: nil, text: "Chat Automation", icon: PresentationResourcesSettings.aiTools, action: { + interaction.editingOpenBusinessChatBots() + })) + items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: "Add a bot to reply to messages on your behalf.")) items[.account]!.append(PeerInfoScreenActionItem(id: ItemAddAccount, text: presentationData.strings.Settings_AddAnotherAccount, alignment: .center, action: { interaction.openSettings(.addAccount) diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index c936e8cdec..1da0fd8d81 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -677,7 +677,11 @@ final class ChatbotSetupScreenComponent: Component { } transition.setPosition(view: self.titleTransformContainer, position: navigationTitleFrame.center) transition.setBounds(view: self.titleTransformContainer, bounds: CGRect(origin: CGPoint(), size: navigationTitleFrame.size)) - transition.setFrame(view: navigationTitleView, frame: CGRect(origin: CGPoint(), size: navigationTitleFrame.size)) + transition.setBounds(view: navigationTitleView, bounds: CGRect(origin: CGPoint(), size: navigationTitleFrame.size)) + transition.setPosition(view: navigationTitleView, position: CGPoint( + x: navigationTitleFrame.size.width * 0.5, + y: navigationTitleFrame.size.height * 0.5 + )) } let bottomContentInset: CGFloat = 24.0 diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/Contents.json new file mode 100644 index 0000000000..75aa24b443 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "voice_message_20.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/voice_message_20.pdf b/submodules/TelegramUI/Images.xcassets/Chat List/VoiceMessageIcon.imageset/voice_message_20.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4d0f9fb5dc8b076d4f46804db689da67f791bba7 GIT binary patch literal 5546 zcma)Ac{o&U*tbNIUM(m|4N?)tV1|)ok1UNPRHQL;FwAC_j4ev*t0b~yUs8xf^4f*6 zyp$+qkJ2I~Ta?Im&I}Xve%JS%KhAZY`?;Ury`Ss3Z+VQ7i84Y(Q&>J2e1s7&RT#z9 zQy2!*(SgA&0J1y71EvNNZ9o$Ez-^hX3|}e$gF9MUERgKjYfFHE#bX&*kf^IGOlQyl zEJ;{rAB&2`c>xTVE8tEb@0FE>5%7Cu9gvo)mQ+K)gJ9}M18n`Q>~MZwI5b{%pRTYD zOPfWal0Y;Vi$o;TwOM;*v3QCrpbdUQWVoy@q#h0Lrfp+nvcLqc_R4xN7*uUI+{ed9 z#YbI*LUV^B&}cMVRSmADrVMf@(|ySdEK8Y8mt$irpcnyk9F0I_5GZ6Ago<^gFd3k# z9P<_g;Bj1hDw9TJqvLUKfC!KPGJ_6Bs373WPk>f%kC5pqY#UT?6cU_;rGo6L96KPV z>fmwO3<84)K!$N1wt4zQ#xKf(vP1%;kTwxZcHb+@QpN*rSSFDntGnw2g@6N`5bByF zfKJD{vp>~T6?EW`NkqagAP!{-whsJv`H(`81SW{Hx;p(Kj3n2fmqLv(C zi&~D%QD;dEIPh!-ETJxhfr@uyVxbgT5^F4hKw`&(y$L7N;RO5==Pn%4rebLfU4#yN z*_!R}|5SlP1TzdwNjNtr!38&^0WzqE50tau*FwUBUr_l7LnXthBTWr8XrE10=Tf2S zvSO0xu%j?lZqV7GKkB@6y|93DY7B#=fyrh^0|1Z*tg=O1jb*rCXaO1-%K(ei2si;| zh0T5g4kp}$0PVrxCPZ-C2*6SB5EA%sOC5>QT%cIN)HzZXOk*Y7k0xL?bV}4ye5Nid z^YR|9sXS*H%~NnrIpuUPF>QSW4|dhs^$(oaS)W>GY_!5jPtbS^mEnkAyUw~tP(Z-W z>9mOOzgk*(~7Fi(&UrYD!}L;fi_8j|wr?tcXfM z!p52&$H6y3$$GI8Qk$C%ilupU&_DM`1lE^}Z58FIIkY{na#T*II69)RM%m zB=fColxc?%bzjXV2uU{ZkaBWJ^R-FyUX4%iNt0LjV9BfXCY0|OT|;j(Y$9*cGqSoQ z_&iwRDSursFEN;(Dmd~$>RX83T4C90&j$jlU;-EPBszGCPU~QJrh?~Ewrt@=ru?N5 ztezrJ7!ukqT+B-j;Z+s6aa^Q)g>Aod>?xSgN^6+n4MBpTV7MfnA0gw=Aa#UK!0;+Y zL`-y*%&J1(EpFZ)}Hz};KF)^&~jEYfA`oWa=*4__$ zKCGJ*o7sKtbnAmyY=SD$V#SWFk!Krgueo|6EMuEud7C%Ala>fQRJ|E1efMOCDN**s z-lDbsLfscWKb*qu&wr-1#cGZFIeN9gYb`B=im1<8A7LNfmsg}}10O_=cCn<&MRkSY z58wBjkL?_j{&sSfJ}aVs>bM?NcEx(unfqsC4c4SyriQAT#L6hHzkEUEA;wkKdw2e& z4!v@V0Mh_N*{ck*up4i;^Ac8PU1G#BqBIO7p29|1)~_ z2A_U4=2XT5>y8_kRH_x$6no@qwny%!%-A%?OqR`9-0R}?R*jg(qZe;oyUnl|ifG#< zZrq+;km>Xh<+$FE(ppTXqd@nDs6UcDQtg$z< zSFk~+t7VQ8PQBy1A}}jZCZVD9!DaLqGc~(R(FN&p-!lMw)dUy3v|hMZ7g(o8a1?a67MHj5wDw(p8DpNTx(X_Q2X^(WSetp ze0q2057R4|_0L0}?QR`zi*I}R!X>*(zv|fhDcJHesMxzFyEmzHxMZTot2fy{mbKk4VVw1r@K#}*I3CbKb0y)K zeyQaF72_3t6;b65tQh}&Q>|YwPG?PDAHP50{H1&7{MhQz?qSV`FYxqE<|l=qmGhT> z_VNw}hX;=b-!iv|xe#5bd^TnV-uutwfLaJp11O(@g-aL1N3FxHF@8_;n(~`Mq8*fi zB#6@GGQ~U3Y%bU_h;Jyvv^-6FIv`%Rd;9Lvn!(z%+K)AT=l#!aJZ~ZgdWg4%UeU9I*Hoof@HP){&SU6_?l*;}#tqv%}Koz^i0kdEvF~ zZhDfp)s8&U7~D^@byezAk!)l<&S{tp7msG5jv|i62GVnfx}3d|^Ck{Yzww*&8=1MY zMq8?gys7g;e!l01LWL8yFWo90m&Df>eIujuql%G)VG7zF{RmC@=PbpmslB;3VozzW zkAGm2Q_?Pq$N7mcxjn6W1ors*D0eZbFJA^6`}vjc_UX3MEoXabhW9znW2S09jTHA* z7*83WpFTb+@Z~tV%|gK>G6@iCf=C5 zV`C+>toro#HHTtue}0*8C$a2Fc^|Fzr^$q6S-fRc=iH0uUaz_Xng;BLN%h^Nz|bGs zGrcqLzOhpJTa_Opmrnuwfg!sEzpOD9HTyC)a{1N4%J~`ncOlmYltmGu6WbJY6a#a{ zif1l1Mw(<*OwL|-+3@(5%)#^fOVZ&%E#KxQ97ojuz1Q}>t7W*3RM)*TU|CTup23@G55{+-?Hn|-?u&2 zP59O@+cBXq`)uNRPD^nM;9^l(7`W!!3>5{OnA#)iT0P~~`Z-tn^Jhtvh*#;xePMED z63>7qVNr1jitAbp+q9f?6ZK=RN6p6{ytr3LujvGj-L`;G8R58~X8!`Tet)t+kb}>r z68tf*`hOffkvz==9*n=*iZMFEp1NzU46S1haEEA zC^Qr#^=!U!qH6eP@~iUv*kt9TVRa-a*K}<%?B>0*u9c<@SD6pZlJ-bn&8fd+chwPQ zhPWkX)%I8LrD9_g-CQ64P!5;W{G<}Gg2#dHK}=d_=k@H)PI!Y7rk00s;HSW4p;o#7 zxa=ai*H1O*`nY&2MRCbTVQ@1;Ljx=wz{8-sstwGU%caTXg6_nI6e5LY3-*ivy8$&s zz#tSG@Upw~zN@wD%>Q|*MMDtF@3OqWHA_!J8L_kPeZ~ zRN78SJ0^tn_rC4mnvN5x6F;=QJN8=pS;qs~!_9mG%tLj(M!wH8mG9T4*y1T6+wY2^ z#NHN~*PYFLeZjknh1w#&PJ}ncTVmF zarauvEvm#mOPy9S39g10p3CxBBP&QAc>MlJ|1QN-N^=j=iW&#E;QLj&%2>y$cZVN1 z9FHk3ZX7gg=%Kk~U3?K1Ousu9cG^!6ld&zo)HWl7Hki3`z~cXDdyCSdDeHye?0glSpHz4Kkh}};e>CL2E??X$?U~Wz zj1A61k$rDBjPlf1KeqUf6oEp(>4(&js@&KkRJmj*_~ryW)DR({Aq12noX*6M!Xz_b zh=tW+BjgUyIT2mVHufow2nb7`Or|hEv_%w7>jYyjM*>%zy?6Fes-N zl9X%GuPzcY2?Ikw4dkMpwjc#;X&2Bj?hAffl+7N>aAX>TLkf_*q+w(O{c@H$9_M>wgqIJ1JWGj4%sPXK%X8ioeA z0bsWcWd{UiM4(fNSYOZycF?5eHz2MU42^+qkhwH16Ky%4I!g0TK20b`f8*2q6Hfyi z@%_%Hp{51~bop5gHS{0)X&_a>s{4%(iT;y@LjR$kh89#Xi)R@$EP)8ngrQ6^GXkq= zmj+thNmWw~fmGAdQ22ex{$s$Q1cpK0<&-qsmf#0K!2lyfXMmN>4i6FmQsm{051RZB DVE}9* literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift index 0080cb5043..a1201fb48a 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift @@ -18,6 +18,7 @@ import TooltipUI import TopMessageReactions import TelegramNotices import PresentationDataUtils +import ChatPresentationInterfaceState extension ChatControllerImpl { func openMessageContextMenu(message: Message, selectAll: Bool, node: ASDisplayNode, frame: CGRect, anyRecognizer: UIGestureRecognizer?, location: CGPoint?) -> Void { @@ -45,13 +46,15 @@ extension ChatControllerImpl { guard let topMessage = messages.first else { return } - + + let canBypassReactionRestrictions = canBypassRestrictions(chatPresentationInterfaceState: self.presentationInterfaceState) + let _ = combineLatest(queue: .mainQueue(), self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: self.context.account.peerId)), contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: self.presentationInterfaceState, context: self.context, messages: updatedMessages, controllerInteraction: self.controllerInteraction, selectAll: selectAll, interfaceInteraction: self.interfaceInteraction, messageNode: node as? ChatMessageItemView), - peerMessageAllowedReactions(context: self.context, message: topMessage), + peerMessageAllowedReactions(context: self.context, message: topMessage, ignoreDefault: canBypassReactionRestrictions), peerMessageSelectedReactions(context: self.context, message: topMessage), - topMessageReactions(context: self.context, message: topMessage, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init)), + topMessageReactions(context: self.context, message: topMessage, subPeerId: self.chatLocation.threadId.flatMap(EnginePeer.Id.init), ignoreDefault: canBypassReactionRestrictions), ApplicationSpecificNotice.getChatTextSelectionTips(accountManager: self.context.sharedContext.accountManager) ).startStandalone(next: { [weak self] peer, actions, allowedReactionsAndStars, selectedReactions, topReactions, chatTextSelectionTips in guard let self else { @@ -370,6 +373,11 @@ extension ChatControllerImpl { controller?.view.endEditing(true) if case .stars = chosenUpdatedReaction.reaction { + if !canSendReactionsToChat(self.presentationInterfaceState) { + controller?.dismiss(completion: {}) + return + } + if isLarge { if let controller { controller.dismiss(completion: { [weak self] in @@ -505,6 +513,11 @@ extension ChatControllerImpl { isFirst = !currentReactions.contains(where: { $0.value == chosenReaction }) } + if removedReaction == nil && !canSendReactionsToChat(self.presentationInterfaceState) { + controller?.dismiss(completion: {}) + return + } + if message.areReactionsTags(accountPeerId: self.context.account.peerId) { if removedReaction == nil, !topReactions.contains(where: { $0.reaction.rawValue == chosenReaction }) { if !self.presentationInterfaceState.isPremium { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 5b62ef7ea5..4a1342c6d8 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1679,6 +1679,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case .default = reaction, strongSelf.chatLocation.peerId == strongSelf.context.account.peerId { return } + if case .default = reaction, !canSendReactionsToChat(strongSelf.presentationInterfaceState) { + return + } if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject { if case let .hashTagSearch(publicPosts) = customChatContents.kind, publicPosts { return @@ -1747,7 +1750,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } - let _ = (peerMessageAllowedReactions(context: strongSelf.context, message: message) + let _ = (peerMessageAllowedReactions(context: strongSelf.context, message: message, ignoreDefault: canBypassRestrictions(chatPresentationInterfaceState: strongSelf.presentationInterfaceState)) |> deliverOnMainQueue).startStandalone(next: { allowedReactions, _ in guard let strongSelf = self else { return @@ -1791,6 +1794,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if case .stars = chosenReaction { + if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { + return + } + if strongSelf.selectPollOptionFeedback == nil { strongSelf.selectPollOptionFeedback = HapticFeedback() } @@ -1957,6 +1964,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } if removedReaction == nil { + if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { + return + } + if !canAddMessageReactions(message: message) { itemNode.openMessageContextMenu() return diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift index c6fc676eb7..8042d75db9 100644 --- a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -261,6 +261,30 @@ extension ChatControllerImpl { })) } + public func presentReactionDeletionOptions(author: Peer, messageId: MessageId) { + guard self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + return + } + let _ = (self.context.sharedContext.chatAvailableMessageActions( + engine: self.context.engine, + accountPeerId: self.context.account.peerId, + messageIds: Set([messageId]), + keepUpdated: false + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] actions in + guard let self, !actions.options.isEmpty else { + return + } + self.presentBanMessageOptions( + accountPeerId: self.context.account.peerId, + author: author, + messageIds: Set([messageId]), + options: actions.options, + reaction: true + ) + }) + } + func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set, options: ChatAvailableMessageActionOptions, reaction: Bool = false) { guard let peerId = self.chatLocation.peerId else { return diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift index 3f53bb2c28..96ce489741 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift @@ -188,33 +188,21 @@ extension ChatControllerImpl { } var dismissController: ((@escaping () -> Void) -> Void)? - let canDeleteReactions = canDeleteOtherUserMessages(message: message) + let canDeleteReactions: Bool + if let channel = message.peers[message.id.peerId] as? TelegramChannel { + canDeleteReactions = channel.hasPermission(.deleteAllMessages) + } else { + canDeleteReactions = false + } let canViewReactions = canViewMessageReactionList(message: message) let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? if canDeleteReactions && canViewReactions { deleteReaction = { [weak self] peer, _ in dismissController?({ [weak self] in - guard let self, self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + guard let self else { return } - let _ = (self.context.sharedContext.chatAvailableMessageActions( - engine: self.context.engine, - accountPeerId: self.context.account.peerId, - messageIds: Set([message.id]), - keepUpdated: false - ) - |> deliverOnMainQueue).startStandalone(next: { [weak self] actions in - guard let self, !actions.options.isEmpty else { - return - } - self.presentBanMessageOptions( - accountPeerId: self.context.account.peerId, - author: peer._asPeer(), - messageIds: Set([message.id]), - options: actions.options, - reaction: true - ) - }) + self.presentReactionDeletionOptions(author: peer._asPeer(), messageId: message.id) }) } } else { @@ -408,6 +396,10 @@ extension ChatControllerImpl { } func openMessageSendStarsScreen(message: Message) { + guard canSendReactionsToChat(self.presentationInterfaceState) else { + return + } + if let current = self.currentSendStarsUndoController { self.currentSendStarsUndoController = nil current.dismiss() diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index befab1cc12..e6a8b66608 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -52,13 +52,6 @@ func canEditMessage(context: AccountContext, limitsConfiguration: EngineConfigur return canEditMessage(accountPeerId: context.account.peerId, limitsConfiguration: limitsConfiguration, message: message) } -func canDeleteOtherUserMessages(message: Message) -> Bool { - guard let channel = message.peers[message.id.peerId] as? TelegramChannel else { - return false - } - return channel.hasPermission(.deleteAllMessages) -} - private func canEditMessage(accountPeerId: PeerId, limitsConfiguration: EngineConfiguration.Limits, message: Message, reschedule: Bool = false) -> Bool { var hasEditRights = false var unlimitedInterval = reschedule @@ -2181,30 +2174,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState } let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? - if canDeleteOtherUserMessages(message: message) { + if let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.hasPermission(.deleteAllMessages) { deleteReaction = { [weak c] peer, _ in c?.dismiss(completion: { - guard let chatController = interfaceInteraction.chatController() as? ChatControllerImpl, chatController.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + guard let chatController = interfaceInteraction.chatController() as? ChatController else { return } - let _ = (context.sharedContext.chatAvailableMessageActions( - engine: context.engine, - accountPeerId: context.account.peerId, - messageIds: Set([message.id]), - keepUpdated: false - ) - |> deliverOnMainQueue).startStandalone(next: { actions in - guard !actions.options.isEmpty else { - return - } - chatController.presentBanMessageOptions( - accountPeerId: context.account.peerId, - author: peer._asPeer(), - messageIds: Set([message.id]), - options: actions.options, - reaction: true - ) - }) + chatController.presentReactionDeletionOptions(author: peer._asPeer(), messageId: message.id) }) } } else { diff --git a/submodules/TelegramUI/Sources/NavigateToChatController.swift b/submodules/TelegramUI/Sources/NavigateToChatController.swift index 90c78a09d6..60f4344fa0 100644 --- a/submodules/TelegramUI/Sources/NavigateToChatController.swift +++ b/submodules/TelegramUI/Sources/NavigateToChatController.swift @@ -182,6 +182,7 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam controller.navigateToMessage(messageLocation: .id(messageId, NavigateToMessageParams(timestamp: timecode, quote: (highlight?.quote).flatMap { quote in NavigateToMessageParams.Quote(string: quote.string, offset: quote.offset) }, subject: highlight?.subject, setupReply: setupReply)), animated: isFirst || params.forceAnimatedScroll, completion: { [weak navigationController, weak controller] in if let navigationController = navigationController, let controller = controller { let _ = navigationController.popToViewController(controller, animated: animated) + params.completion(controller) } }, customPresentProgress: { [weak navigationController] c, a in (navigationController?.viewControllers.last as? ViewController)?.present(c, in: .window(.root), with: a) From 511a58e51af8fb521b430098f861baa05f1a1102 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 16:56:02 +0400 Subject: [PATCH 03/69] Sticker pack improvements --- .../Sources/FeaturedStickersScreen.swift | 15 +++ .../FeaturedStickerPacksController.swift | 13 +- .../InstalledStickerPacksController.swift | 2 +- .../Sources/StickerPackScreen.swift | 31 ++++- .../Sources/State/AccountStateManager.swift | 17 +++ ...onizeInstalledStickerPacksOperations.swift | 124 ++++++++++-------- .../StickerPackInteractiveOperations.swift | 30 +++-- .../Stickers/TelegramEngineStickers.swift | 4 +- .../Sources/StickerPickerScreen.swift | 4 +- 9 files changed, 161 insertions(+), 79 deletions(-) diff --git a/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift b/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift index 944fc26856..ccc3ea7841 100644 --- a/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift +++ b/submodules/FeaturedStickersScreen/Sources/FeaturedStickersScreen.swift @@ -863,6 +863,8 @@ public final class FeaturedStickersScreen: ViewController { fileprivate var searchNavigationNode: SearchNavigationContentNode? + private var eventsDisposable: Disposable? + public init(context: AccountContext, highlightedPackId: ItemCollectionId?, forceTheme: PresentationTheme? = nil, stickerActionTitle: String? = nil, sendSticker: ((FileMediaReference, UIView?, CGRect?) -> Bool)? = nil) { self.context = context self.highlightedPackId = highlightedPackId @@ -906,6 +908,18 @@ public final class FeaturedStickersScreen: ViewController { } } }) + + self.eventsDisposable = (context.account.stateManager.installedStickerPacksArchivedEvents + |> deliverOnMainQueue).startStrict(next: { [weak self] count in + guard let self else { + return + } + if count == 0 { + return + } + let presentationData = self.presentationData + self.push(textAlertController(context: self.context, updatedPresentationData: nil, title: nil, text: presentationData.strings.ArchivedPacksAlert_Title, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})])) + }) } required init(coder aDecoder: NSCoder) { @@ -914,6 +928,7 @@ public final class FeaturedStickersScreen: ViewController { deinit { self.presentationDataDisposable?.dispose() + self.eventsDisposable?.dispose() } private func updatePresentationData() { diff --git a/submodules/SettingsUI/Sources/Stickers/FeaturedStickerPacksController.swift b/submodules/SettingsUI/Sources/Stickers/FeaturedStickerPacksController.swift index e0baafe727..c62b4f08c5 100644 --- a/submodules/SettingsUI/Sources/Stickers/FeaturedStickerPacksController.swift +++ b/submodules/SettingsUI/Sources/Stickers/FeaturedStickerPacksController.swift @@ -172,7 +172,7 @@ public func featuredStickerPacksController(context: AccountContext) -> ViewContr presentStickerPackController?(info) }, addPack: { info in let _ = (context.engine.stickers.loadedStickerPack(reference: .id(id: info.id.id, accessHash: info.accessHash), forceActualized: false) - |> mapToSignal { result -> Signal in + |> mapToSignal { result -> Signal in switch result { case let .result(info, items, installed): if installed { @@ -186,9 +186,18 @@ public func featuredStickerPacksController(context: AccountContext) -> ViewContr break } return .complete() - } |> deliverOnMainQueue).start() + } |> deliverOnMainQueue).startStandalone() }) + actionsDisposable.add((context.account.stateManager.installedStickerPacksArchivedEvents + |> deliverOnMainQueue).startStandalone(next: { count in + if count == 0 { + return + } + let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) + presentControllerImpl?(textAlertController(context: context, updatedPresentationData: nil, title: nil, text: presentationData.strings.ArchivedPacksAlert_Title, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), nil) + })) + let stickerPacks = Promise() stickerPacks.set(context.account.postbox.combinedView(keys: [.itemCollectionInfos(namespaces: [Namespaces.ItemCollection.CloudStickerPacks])])) diff --git a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift index d290580db0..59dda715e0 100644 --- a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift +++ b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift @@ -864,7 +864,7 @@ public func installedStickerPacksController(context: AccountContext, mode: Insta if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break diff --git a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift index ac6179b47e..538b72044c 100644 --- a/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift +++ b/submodules/StickerPackPreviewUI/Sources/StickerPackScreen.swift @@ -1517,13 +1517,20 @@ private final class StickerPackContainer: ASDisplayNode { if let (info, items, installed) = self.currentStickerPack { var dismissed = false switch self.decideNextAction(self, installed ? .remove : .add) { - case .dismiss: - self.requestDismiss() - dismissed = true - case .navigatedNext, .ignored: - self.updateStickerPackContents([.result(info: StickerPackCollectionInfo.Accessor(info), items: items, installed: !installed)], hasPremium: false) + case .dismiss: + self.requestDismiss() + dismissed = true + case .navigatedNext, .ignored: + self.updateStickerPackContents([.result(info: StickerPackCollectionInfo.Accessor(info), items: items, installed: !installed)], hasPremium: false) } + guard let controller = self.controller else { + return + } + let navigationController = controller.parentNavigationController ?? (controller.navigationController as? NavigationController) + let context = self.context + let strings = self.presentationData.strings + let actionPerformed = self.controller?.actionPerformed if installed { let _ = (self.context.engine.stickers.removeStickerPackInteractively(id: info.id, option: .delete) @@ -1536,7 +1543,19 @@ private final class StickerPackContainer: ASDisplayNode { } }) } else { - let _ = self.context.engine.stickers.addStickerPackInteractively(info: info, items: items).start() + let _ = self.context.engine.stickers.addStickerPackInteractively(info: info, items: items, noDelay: true).startStandalone() + let _ = (self.context.account.stateManager.installedStickerPacksArchivedEvents + |> deliverOnMainQueue + |> take(1) + |> timeout(3.0, queue: .mainQueue(), alternate: .single(0))).startStandalone(next: { [weak navigationController] count in + if count == 0 { + return + } + guard let navigationController else { + return + } + navigationController.pushViewController(textAlertController(context: context, updatedPresentationData: controller.updatedPresentationData, title: nil, text: strings.ArchivedPacksAlert_Title, actions: [TextAlertAction(type: .defaultAction, title: strings.Common_OK, action: {})])) + }) if dismissed { actionPerformed?([(info, items, .add)]) } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index eea995bc40..b559213273 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -360,6 +360,11 @@ public final class AccountStateManager { return self.starRefBotConnectionEventsPipe.signal() } + fileprivate let installedStickerPacksArchivedEventsPipe = ValuePipe() + var installedStickerPacksArchivedEvents: Signal { + return self.installedStickerPacksArchivedEventsPipe.signal() + } + private var updatedWebpageContexts: [MediaId: UpdatedWebpageSubscriberContext] = [:] private var updatedPeersNearbyContext = UpdatedPeersNearbySubscriberContext() private var updatedStarsBalanceContext = UpdatedStarsBalanceSubscriberContext() @@ -2036,6 +2041,18 @@ public final class AccountStateManager { } } + public var installedStickerPacksArchivedEvents: Signal { + return self.impl.signalWith { impl, subscriber in + return impl.installedStickerPacksArchivedEvents.start(next: subscriber.putNext, error: subscriber.putError, completed: subscriber.putCompletion) + } + } + + func installedStickerPacksArchived(count: Int) { + self.impl.with { impl in + impl.installedStickerPacksArchivedEventsPipe.putNext(count) + } + } + var botPreviewUpdates: Signal<[InternalBotPreviewUpdate], NoError> { return self.impl.signalWith { impl, subscriber in return impl.botPreviewUpdates.start(next: subscriber.putNext, error: subscriber.putError, completed: subscriber.putCompletion) diff --git a/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift b/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift index d7e1728e67..e3a98beddb 100644 --- a/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift +++ b/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift @@ -228,49 +228,60 @@ private func resolveStickerPacks(network: Network, remoteInfos: [ItemCollectionI } } -private func installRemoteStickerPacks(network: Network, infos: [StickerPackCollectionInfo]) -> Signal, NoError> { - var signals: [Signal, NoError>] = [] +private struct InstallRemoteStickerPacksResult { + var ids: Set + var archivedCount: Int + + init(ids: Set, archivedCount: Int) { + self.ids = ids + self.archivedCount = archivedCount + } +} + +private func installRemoteStickerPacks(network: Network, infos: [StickerPackCollectionInfo]) -> Signal { + var signals: [Signal] = [] for info in infos { let install = network.request(Api.functions.messages.installStickerSet(stickerset: .inputStickerSetID(.init(id: info.id.id, accessHash: info.accessHash)), archived: .boolFalse)) - |> map { result -> Set in - switch result { - case .stickerSetInstallResultSuccess: - return Set() - case let .stickerSetInstallResultArchive(stickerSetInstallResultArchiveData): - let archivedSets = stickerSetInstallResultArchiveData.sets - var archivedIds = Set() - for archivedSet in archivedSets { - switch archivedSet { - case let .stickerSetCovered(stickerSetCoveredData): - let set = stickerSetCoveredData.set - archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) - case let .stickerSetMultiCovered(stickerSetMultiCoveredData): - let set = stickerSetMultiCoveredData.set - archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) - case let .stickerSetFullCovered(stickerSetFullCoveredData): - let set = stickerSetFullCoveredData.set - archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) - case let .stickerSetNoCovered(stickerSetNoCoveredData): - let set = stickerSetNoCoveredData.set - archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) - } - } - return archivedIds + |> map { result -> InstallRemoteStickerPacksResult in + switch result { + case .stickerSetInstallResultSuccess: + return InstallRemoteStickerPacksResult(ids: Set(), archivedCount: 0) + case let .stickerSetInstallResultArchive(stickerSetInstallResultArchiveData): + let archivedSets = stickerSetInstallResultArchiveData.sets + var archivedIds = Set() + for archivedSet in archivedSets { + switch archivedSet { + case let .stickerSetCovered(stickerSetCoveredData): + let set = stickerSetCoveredData.set + archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) + case let .stickerSetMultiCovered(stickerSetMultiCoveredData): + let set = stickerSetMultiCoveredData.set + archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) + case let .stickerSetFullCovered(stickerSetFullCoveredData): + let set = stickerSetFullCoveredData.set + archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) + case let .stickerSetNoCovered(stickerSetNoCoveredData): + let set = stickerSetNoCoveredData.set + archivedIds.insert(StickerPackCollectionInfo(apiSet: set, namespace: info.id.namespace).id) + } } + return InstallRemoteStickerPacksResult(ids: archivedIds, archivedCount: archivedIds.count) } - |> `catch` { _ -> Signal, NoError> in - return .single(Set()) - } + } + |> `catch` { _ -> Signal in + return .single(InstallRemoteStickerPacksResult(ids: Set(), archivedCount: 0)) + } signals.append(install) } return combineLatest(signals) - |> map { idsSets -> Set in - var result = Set() - for ids in idsSets { - result.formUnion(ids) - } - return result + |> map { results -> InstallRemoteStickerPacksResult in + var result = InstallRemoteStickerPacksResult(ids: Set(), archivedCount: 0) + for resultValue in results { + result.ids.formUnion(resultValue.ids) + result.archivedCount += resultValue.archivedCount } + return result + } } private func removeRemoteStickerPacks(network: Network, infos: [StickerPackCollectionInfo]) -> Signal { @@ -341,12 +352,12 @@ private func reorderRemoteStickerPacks(network: Network, namespace: SynchronizeI private func synchronizeInstalledStickerPacks(transaction: Transaction, postbox: Postbox, network: Network, stateManager: AccountStateManager, namespace: SynchronizeInstalledStickerPacksOperationNamespace, operation: SynchronizeInstalledStickerPacksOperation) -> Signal { let collectionNamespace: ItemCollectionId.Namespace switch namespace { - case .stickers: - collectionNamespace = Namespaces.ItemCollection.CloudStickerPacks - case .masks: - collectionNamespace = Namespaces.ItemCollection.CloudMaskPacks - case .emoji: - collectionNamespace = Namespaces.ItemCollection.CloudEmojiPacks + case .stickers: + collectionNamespace = Namespaces.ItemCollection.CloudStickerPacks + case .masks: + collectionNamespace = Namespaces.ItemCollection.CloudMaskPacks + case .emoji: + collectionNamespace = Namespaces.ItemCollection.CloudEmojiPacks } let localCollectionInfos = transaction.getItemCollectionsInfos(namespace: collectionNamespace).map { $0.1 as! StickerPackCollectionInfo } @@ -466,12 +477,12 @@ func debugFetchAllStickers(account: Account) -> Signal { private func continueSynchronizeInstalledStickerPacks(transaction: Transaction, postbox: Postbox, network: Network, stateManager: AccountStateManager, namespace: SynchronizeInstalledStickerPacksOperationNamespace, operation: SynchronizeInstalledStickerPacksOperation) -> Signal { let collectionNamespace: ItemCollectionId.Namespace switch namespace { - case .stickers: - collectionNamespace = Namespaces.ItemCollection.CloudStickerPacks - case .masks: - collectionNamespace = Namespaces.ItemCollection.CloudMaskPacks - case .emoji: - collectionNamespace = Namespaces.ItemCollection.CloudEmojiPacks + case .stickers: + collectionNamespace = Namespaces.ItemCollection.CloudStickerPacks + case .masks: + collectionNamespace = Namespaces.ItemCollection.CloudMaskPacks + case .emoji: + collectionNamespace = Namespaces.ItemCollection.CloudEmojiPacks } let localCollectionInfos = transaction.getItemCollectionsInfos(namespace: collectionNamespace).map { $0.1 as! StickerPackCollectionInfo } @@ -479,12 +490,12 @@ private func continueSynchronizeInstalledStickerPacks(transaction: Transaction, let request: Signal switch namespace { - case .stickers: - request = network.request(Api.functions.messages.getAllStickers(hash: initialLocalHash)) - case .masks: - request = network.request(Api.functions.messages.getMaskStickers(hash: initialLocalHash)) - case .emoji: - request = network.request(Api.functions.messages.getEmojiStickers(hash: initialLocalHash)) + case .stickers: + request = network.request(Api.functions.messages.getAllStickers(hash: initialLocalHash)) + case .masks: + request = network.request(Api.functions.messages.getMaskStickers(hash: initialLocalHash)) + case .emoji: + request = network.request(Api.functions.messages.getEmojiStickers(hash: initialLocalHash)) } let sequence = request @@ -661,7 +672,12 @@ private func continueSynchronizeInstalledStickerPacks(transaction: Transaction, |> then(Signal.single(Void()))) |> mapToSignal { _ -> Signal, NoError> in return installRemoteStickerPacks(network: network, infos: addRemoteCollectionInfos) - |> mapToSignal { ids -> Signal, NoError> in + |> mapToSignal { result -> Signal, NoError> in + let ids = result.ids + if result.archivedCount != 0 || "".isEmpty { + stateManager.installedStickerPacksArchived(count: result.archivedCount + 1) + } + return (reorderRemoteStickerPacks(network: network, namespace: namespace, ids: resultingCollectionInfos.map({ $0.0.id }).filter({ !ids.contains($0) })) |> then(Signal.single(Void()))) |> map { _ -> Set in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerPackInteractiveOperations.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerPackInteractiveOperations.swift index 359a1a6a58..f622cbc6f5 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerPackInteractiveOperations.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerPackInteractiveOperations.swift @@ -2,26 +2,28 @@ import Foundation import Postbox import SwiftSignalKit +public struct AddStickerPackResult { +} -func _internal_addStickerPackInteractively(postbox: Postbox, info: StickerPackCollectionInfo, items: [StickerPackItem], positionInList: Int? = nil) -> Signal { - return postbox.transaction { transaction -> Void in +func _internal_addStickerPackInteractively(postbox: Postbox, info: StickerPackCollectionInfo, items: [StickerPackItem], positionInList: Int? = nil, noDelay: Bool) -> Signal { + return postbox.transaction { transaction -> AddStickerPackResult in let namespace: SynchronizeInstalledStickerPacksOperationNamespace? switch info.id.namespace { - case Namespaces.ItemCollection.CloudStickerPacks: - namespace = .stickers - case Namespaces.ItemCollection.CloudMaskPacks: - namespace = .masks - case Namespaces.ItemCollection.CloudEmojiPacks: - namespace = .emoji - default: - namespace = nil + case Namespaces.ItemCollection.CloudStickerPacks: + namespace = .stickers + case Namespaces.ItemCollection.CloudMaskPacks: + namespace = .masks + case Namespaces.ItemCollection.CloudEmojiPacks: + namespace = .emoji + default: + namespace = nil } - if let namespace = namespace { + if let namespace { var mappedInfo = info if items.isEmpty { mappedInfo = StickerPackCollectionInfo(id: info.id, flags: info.flags, accessHash: info.accessHash, title: info.title, shortName: info.shortName, thumbnail: info.thumbnail, thumbnailFileId: info.thumbnailFileId, immediateThumbnailData: info.immediateThumbnailData, hash: Int32(bitPattern: arc4random()), count: info.count) } - addSynchronizeInstalledStickerPacksOperation(transaction: transaction, namespace: namespace, content: .add([mappedInfo.id]), noDelay: items.isEmpty) + addSynchronizeInstalledStickerPacksOperation(transaction: transaction, namespace: namespace, content: .add([mappedInfo.id]), noDelay: noDelay) var updatedInfos = transaction.getItemCollectionsInfos(namespace: mappedInfo.id.namespace).map { $0.1 as! StickerPackCollectionInfo } if let index = updatedInfos.firstIndex(where: { $0.id == mappedInfo.id }) { let currentInfo = updatedInfos[index] @@ -42,6 +44,10 @@ func _internal_addStickerPackInteractively(postbox: Postbox, info: StickerPackCo transaction.replaceItemCollectionItems(collectionId: mappedInfo.id, items: indexedItems) } transaction.replaceItemCollectionInfos(namespace: mappedInfo.id.namespace, itemCollectionInfos: updatedInfos.map { ($0.id, $0) }) + + return AddStickerPackResult() + } else { + return AddStickerPackResult() } } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift index b55855fda3..332ca02758 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift @@ -62,8 +62,8 @@ public extension TelegramEngine { return _internal_searchGifs(account: self.account, query: query, nextOffset: nextOffset) } - public func addStickerPackInteractively(info: StickerPackCollectionInfo, items: [StickerPackItem], positionInList: Int? = nil) -> Signal { - return _internal_addStickerPackInteractively(postbox: self.account.postbox, info: info, items: items, positionInList: positionInList) + public func addStickerPackInteractively(info: StickerPackCollectionInfo, items: [StickerPackItem], positionInList: Int? = nil, noDelay: Bool = false) -> Signal { + return _internal_addStickerPackInteractively(postbox: self.account.postbox, info: info, items: items, positionInList: positionInList, noDelay: noDelay) } public func removeStickerPackInteractively(id: ItemCollectionId, option: RemoveStickerPackOption) -> Signal<(Int, [ItemCollectionItem])?, NoError> { diff --git a/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift b/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift index 005fccb8dc..985e199f5c 100644 --- a/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift +++ b/submodules/TelegramUI/Components/StickerPickerScreen/Sources/StickerPickerScreen.swift @@ -960,7 +960,7 @@ public class StickerPickerScreen: ViewController { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break @@ -1384,7 +1384,7 @@ public class StickerPickerScreen: ViewController { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break From a79447e430c42dd9d680fb5e9539f2bc4a3795a8 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 16:57:08 +0400 Subject: [PATCH 04/69] Remove debugging --- .../ManagedSynchronizeInstalledStickerPacksOperations.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift b/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift index e3a98beddb..58c7a11fef 100644 --- a/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift +++ b/submodules/TelegramCore/Sources/State/ManagedSynchronizeInstalledStickerPacksOperations.swift @@ -674,8 +674,8 @@ private func continueSynchronizeInstalledStickerPacks(transaction: Transaction, return installRemoteStickerPacks(network: network, infos: addRemoteCollectionInfos) |> mapToSignal { result -> Signal, NoError> in let ids = result.ids - if result.archivedCount != 0 || "".isEmpty { - stateManager.installedStickerPacksArchived(count: result.archivedCount + 1) + if result.archivedCount != 0 { + stateManager.installedStickerPacksArchived(count: result.archivedCount) } return (reorderRemoteStickerPacks(network: network, namespace: namespace, ids: resultingCollectionInfos.map({ $0.0.id }).filter({ !ids.contains($0) })) From 51a7348db61fe1a109ad13df59458c3e03f86d2f Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 16:57:44 +0400 Subject: [PATCH 05/69] Separate failed message events between main and scheduled --- .../Sources/State/PendingMessageManager.swift | 20 +++- .../Chat/ChatControllerLoadDisplayNode.swift | 106 ++++++++++-------- 2 files changed, 73 insertions(+), 53 deletions(-) diff --git a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift index fc6a80983e..02d29d7a6b 100644 --- a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift +++ b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift @@ -117,7 +117,7 @@ public struct PeerPendingMessageDelivered { private final class PeerPendingMessagesSummaryContext { var messageDeliveredSubscribers = Bag<([PeerPendingMessageDelivered]) -> Void>() - var messageFailedSubscribers = Bag<(PendingMessageFailureReason) -> Void>() + var messageFailedSubscribers = Bag<(MessageId.Namespace, PendingMessageFailureReason) -> Void>() var newTopicEvents = Bag<(PendingMessageManager.NewTopicEvent) -> Void>() var isEmpty: Bool { @@ -1413,7 +1413,7 @@ public final class PendingMessageManager { if let context = strongSelf.peerSummaryContexts[message.id.peerId] { for subscriber in context.messageFailedSubscribers.copyItems() { - subscriber(failureReason) + subscriber(message.id.namespace, failureReason) } } } @@ -2029,7 +2029,7 @@ public final class PendingMessageManager { if let context = strongSelf.peerSummaryContexts[message.id.peerId] { for subscriber in context.messageFailedSubscribers.copyItems() { - subscriber(failureReason) + subscriber(message.id.namespace, failureReason) } } } @@ -2232,7 +2232,7 @@ public final class PendingMessageManager { } } - public func failedMessageEvents(peerId: PeerId) -> Signal { + public func failedMessageEvents(peerId: PeerId, isScheduled: Bool) -> Signal { return Signal { subscriber in let disposable = MetaDisposable() @@ -2245,8 +2245,16 @@ public final class PendingMessageManager { self.peerSummaryContexts[peerId] = summaryContext } - let index = summaryContext.messageFailedSubscribers.add({ reason in - subscriber.putNext(reason) + let index = summaryContext.messageFailedSubscribers.add({ namespace, reason in + if isScheduled { + if Namespaces.Message.allScheduled.contains(namespace) { + subscriber.putNext(reason) + } + } else { + if !Namespaces.Message.allScheduled.contains(namespace) { + subscriber.putNext(reason) + } + } }) disposable.set(ActionDisposable { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index bf272f5846..b36b81a3ca 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -5123,56 +5123,68 @@ extension ChatControllerImpl { } } })) + + let isScheduledMessages: Bool + if case .scheduledMessages = self.presentationInterfaceState.subject { + isScheduledMessages = true + } else { + isScheduledMessages = false + } - self.failedMessageEventsDisposable.set((self.context.account.pendingMessageManager.failedMessageEvents(peerId: peerId) + self.failedMessageEventsDisposable.set((self.context.account.pendingMessageManager.failedMessageEvents(peerId: peerId, isScheduled: isScheduledMessages) |> deliverOnMainQueue).startStrict(next: { [weak self] reason in - if let strongSelf = self, strongSelf.currentFailedMessagesAlertController == nil { - let text: String - var title: String? - let moreInfo: Bool - switch reason { - case .flood: - text = strongSelf.presentationData.strings.Conversation_SendMessageErrorFlood - moreInfo = true - case .sendingTooFast: - text = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooFast - title = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooFastTitle - moreInfo = false - case .publicBan: - text = strongSelf.presentationData.strings.Conversation_SendMessageErrorGroupRestricted - moreInfo = true - case .mediaRestricted: - text = strongSelf.restrictedSendingContentsText() - moreInfo = false - case .slowmodeActive: - text = strongSelf.presentationData.strings.Chat_SlowmodeSendError - moreInfo = false - case .tooMuchScheduled: - text = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooMuchScheduled - moreInfo = false - case .voiceMessagesForbidden: - strongSelf.interfaceInteraction?.displayRestrictedInfo(.premiumVoiceMessages, .alert) - return - case .nonPremiumMessagesForbidden: - if let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer { - text = strongSelf.presentationData.strings.Conversation_SendMessageErrorNonPremiumForbidden(EnginePeer(peer).compactDisplayTitle).string - moreInfo = false - } else { - return - } - } - let actions: [TextAlertAction] - if moreInfo { - actions = [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Generic_ErrorMoreInfo, action: { - self?.openPeerMention("spambot", navigation: .chat(textInputState: nil, subject: nil, peekData: nil)) - }), TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_OK, action: {})] - } else { - actions = [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})] - } - let controller = textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: title, text: text, actions: actions) - strongSelf.currentFailedMessagesAlertController = controller - strongSelf.present(controller, in: .window(.root)) + guard let strongSelf = self else { + return } + guard strongSelf.currentFailedMessagesAlertController == nil else { + return + } + + let text: String + var title: String? + let moreInfo: Bool + switch reason { + case .flood: + text = strongSelf.presentationData.strings.Conversation_SendMessageErrorFlood + moreInfo = true + case .sendingTooFast: + text = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooFast + title = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooFastTitle + moreInfo = false + case .publicBan: + text = strongSelf.presentationData.strings.Conversation_SendMessageErrorGroupRestricted + moreInfo = true + case .mediaRestricted: + text = strongSelf.restrictedSendingContentsText() + moreInfo = false + case .slowmodeActive: + text = strongSelf.presentationData.strings.Chat_SlowmodeSendError + moreInfo = false + case .tooMuchScheduled: + text = strongSelf.presentationData.strings.Conversation_SendMessageErrorTooMuchScheduled + moreInfo = false + case .voiceMessagesForbidden: + strongSelf.interfaceInteraction?.displayRestrictedInfo(.premiumVoiceMessages, .alert) + return + case .nonPremiumMessagesForbidden: + if let peer = strongSelf.presentationInterfaceState.renderedPeer?.chatMainPeer { + text = strongSelf.presentationData.strings.Conversation_SendMessageErrorNonPremiumForbidden(EnginePeer(peer).compactDisplayTitle).string + moreInfo = false + } else { + return + } + } + let actions: [TextAlertAction] + if moreInfo { + actions = [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Generic_ErrorMoreInfo, action: { + self?.openPeerMention("spambot", navigation: .chat(textInputState: nil, subject: nil, peekData: nil)) + }), TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_OK, action: {})] + } else { + actions = [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_OK, action: {})] + } + let controller = textAlertController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, title: title, text: text, actions: actions) + strongSelf.currentFailedMessagesAlertController = controller + strongSelf.present(controller, in: .window(.root)) })) self.sentPeerMediaMessageEventsDisposable.dispose() From 027ac77ad7774528affe64fc8d0e04323b704e19 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 16:58:04 +0400 Subject: [PATCH 06/69] Various improvements --- .../Sources/NotificationService.swift | 2 +- .../Sources/AvatarEditorScreen.swift | 4 +- .../Sources/ChatMessageBubbleItemNode.swift | 6 +- .../ChatMessageTextBubbleContentNode.swift | 21 ++--- .../ChatTextInputActionButtonsNode.swift | 86 ++++++++++++++++++- .../Sources/ChatTextInputPanelNode.swift | 4 + .../Sources/ChatEntityKeyboardInputNode.swift | 2 +- .../Sources/StickerAttachmentScreen.swift | 4 +- ...tControllerExtractedPresentationNode.swift | 37 +++++++- .../EmojiStatusSelectionComponent.swift | 30 +++---- .../Sources/EmojiPagerContentSignals.swift | 3 + .../Sources/InteractiveTextComponent.swift | 44 +++++++--- .../Sources/NavigationBarImpl.swift | 21 ++--- .../Sources/PeerInfoHeaderNode.swift | 5 +- ...extProcessingStyleSelectionComponent.swift | 3 +- .../TelegramUI/Sources/ChatController.swift | 10 +++ .../Sources/ChatControllerNode.swift | 4 +- ...textResultsChatInputContextPanelNode.swift | 58 +++++++++---- third-party/td/build-td-bazel.sh | 5 +- 19 files changed, 263 insertions(+), 86 deletions(-) diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 72292568a5..d52e425248 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -2958,7 +2958,7 @@ extension Customoji { if let cg = (image as UIImage).cgImage { return cg } var rendered: CGImage? - let work = { rendered = renderCGImage(image as! UIImage) } + let work = { rendered = renderCGImage(image) } if Thread.isMainThread { work() } else { diff --git a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift index 1ada4ace6f..19df7c4f3d 100644 --- a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift +++ b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift @@ -580,7 +580,7 @@ final class AvatarEditorScreenComponent: Component { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break @@ -718,7 +718,7 @@ final class AvatarEditorScreenComponent: Component { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index e161ea06b6..8fb75e72b5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -329,7 +329,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ messageText = updatingMedia.text } - if !messageText.isEmpty || isUnsupportedMedia || isStoryWithText { + if !messageText.isEmpty || message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) || isUnsupportedMedia || isStoryWithText { if !skipText { if case .group = item.content, !isFile { messageWithCaptionToAdd = (message, itemAttributes) @@ -2262,7 +2262,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI bubbleReactions = ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: []) } if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview { - bottomNodeMergeStatus = .Both + bottomNodeMergeStatus = .Right } var currentCredibilityIcon: (EmojiStatusComponent.Content, UIColor?)? @@ -6259,7 +6259,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if strongSelf.backgroundNode.supernode != nil, let backgroundView = strongSelf.backgroundNode.view.snapshotContentTree(unhide: true) { let backgroundContainer = UIView() - let backdropView = strongSelf.backgroundWallpaperNode.view.snapshotContentTree(unhide: true) + let backdropView = strongSelf.backgroundWallpaperNode.view.snapshotContentTree(unhide: true, keepPortals: true) if let backdropView = backdropView { let backdropFrame = strongSelf.backgroundWallpaperNode.layer.convert(strongSelf.backgroundWallpaperNode.bounds, to: strongSelf.backgroundNode.layer) backdropView.frame = backdropFrame diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index 379bac9399..8d8872ccd6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -698,6 +698,15 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } } + var hasDraft = false + if item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) { + hasDraft = true + } + var hadDraft = false + if let previousItem, previousItem.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) { + hadDraft = true + } + let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0) let (textLayout, textApply) = textLayout(InteractiveTextNodeLayoutArguments( attributedString: attributedText, @@ -712,18 +721,10 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { displayContentsUnderSpoilers: displayContentsUnderSpoilers.value, customTruncationToken: customTruncationToken, expandedBlocks: expandedBlockIds, - computeCharacterRects: true + computeCharacterRects: true, + minWidth: (attributedText.string.isEmpty && hasDraft) ? 40.0 : nil )) - var hasDraft = false - if item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) { - hasDraft = true - } - var hadDraft = false - if let previousItem, previousItem.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) { - hadDraft = true - } - var maxGlyphCount = currentMaxGlyphCount if maxGlyphCount == nil && (hasDraft || hadDraft) { maxGlyphCount = previousGlyphCount diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputActionButtonsNode/Sources/ChatTextInputActionButtonsNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputActionButtonsNode/Sources/ChatTextInputActionButtonsNode.swift index 0a453ead98..1569743128 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputActionButtonsNode/Sources/ChatTextInputActionButtonsNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputActionButtonsNode/Sources/ChatTextInputActionButtonsNode.swift @@ -173,6 +173,10 @@ public final class ChatTextInputActionButtonsNode: ASDisplayNode, ChatSendMessag public var customSendColor: UIColor? public var isSendDisabled: Bool = false + private var slowmodeProgressTimestamp: (duration: Int32, timestamp: Int32)? + private var slowmodeProgressTimer: Foundation.Timer? + private var slowmodeProgressLayer: SimpleShapeLayer? + public init(context: AccountContext, presentationInterfaceState: ChatPresentationInterfaceState, presentationContext: ChatPresentationContext?, presentController: @escaping (ViewController) -> Void) { self.context = context self.presentationContext = presentationContext @@ -255,6 +259,10 @@ public final class ChatTextInputActionButtonsNode: ASDisplayNode, ChatSendMessag } } + deinit { + self.slowmodeProgressTimer?.invalidate() + } + override public func didLoad() { super.didLoad() @@ -357,8 +365,72 @@ public final class ChatTextInputActionButtonsNode: ASDisplayNode, ChatSendMessag transition.updateFrame(layer: self.micButton.layer, frame: CGRect(origin: CGPoint(), size: size)) self.micButton.layoutItems() + var sendSlowmodeTimerTimestamp: (duration: Int32, timestamp: Int32)? + if let slowmodeState = interfaceState.slowmodeState { + switch slowmodeState.variant { + case .pendingMessages: + break + case let .timestamp(timeoutTimestamp): + sendSlowmodeTimerTimestamp = (slowmodeState.timeout, timeoutTimestamp) + } + } + let sendButtonBackgroundFrame = CGRect(origin: CGPoint(), size: innerSize).insetBy(dx: 3.0, dy: 3.0) - transition.updateFrame(view: self.sendButtonBackgroundView, frame: sendButtonBackgroundFrame) + + let slowmodeInset: CGFloat = 4.0 + + self.slowmodeProgressTimestamp = sendSlowmodeTimerTimestamp + if sendSlowmodeTimerTimestamp != nil { + let slowmodeProgressLayer: SimpleShapeLayer + var slowmodeProgressTransition = transition + if let current = self.slowmodeProgressLayer { + slowmodeProgressLayer = current + } else { + slowmodeProgressTransition = .immediate + slowmodeProgressLayer = SimpleShapeLayer() + self.slowmodeProgressLayer = slowmodeProgressLayer + self.sendButtonBackgroundView.layer.superlayer?.insertSublayer(slowmodeProgressLayer, below: self.sendButtonBackgroundView.layer) + + slowmodeProgressLayer.fillColor = nil + slowmodeProgressLayer.lineWidth = 2.0 + slowmodeProgressLayer.lineCap = .round + } + + slowmodeProgressLayer.strokeColor = (self.customSendColor ?? interfaceState.theme.chat.inputPanel.panelControlAccentColor).cgColor + + if slowmodeProgressLayer.bounds.size != sendButtonBackgroundFrame.size { + let pathFrame = CGRect(origin: CGPoint(), size: sendButtonBackgroundFrame.size).insetBy(dx: 2.0, dy: 2.0) + slowmodeProgressLayer.path = UIBezierPath(roundedRect: pathFrame, cornerRadius: pathFrame.height * 0.5).cgPath + } + slowmodeProgressTransition.updateFrame(layer: slowmodeProgressLayer, frame: sendButtonBackgroundFrame) + + if self.slowmodeProgressTimer == nil { + self.slowmodeProgressTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + self.updateSlowmodeProgress() + }) + } + self.updateSlowmodeProgress() + } else { + if let slowmodeProgressLayer = self.slowmodeProgressLayer { + self.slowmodeProgressLayer = nil + slowmodeProgressLayer.removeFromSuperlayer() + } + if let slowmodeProgressTimer = self.slowmodeProgressTimer { + self.slowmodeProgressTimer = nil + slowmodeProgressTimer.invalidate() + } + } + + ComponentTransition(transition).setPosition(view: self.sendButtonBackgroundView, position: sendButtonBackgroundFrame.center) + ComponentTransition(transition).setBounds(view: self.sendButtonBackgroundView, bounds: CGRect(origin: CGPoint(), size: sendButtonBackgroundFrame.size)) + var sendButtonBackgroundScale: CGFloat = 1.0 + if sendSlowmodeTimerTimestamp != nil, let image = self.sendButtonBackgroundView.image { + sendButtonBackgroundScale = (image.size.height - slowmodeInset * 2.0) / image.size.height + } + transition.updateTransformScale(layer: self.sendButtonBackgroundView.layer, scale: sendButtonBackgroundScale) if self.isSendDisabled { transition.updateTintColor(view: self.sendButtonBackgroundView, color: interfaceState.theme.chat.inputPanel.panelControlAccentColor.withMultiplied(hue: 1.0, saturation: 0.0, brightness: 0.5).withMultipliedAlpha(0.25)) @@ -441,6 +513,18 @@ public final class ChatTextInputActionButtonsNode: ASDisplayNode, ChatSendMessag return innerSize } + private func updateSlowmodeProgress() { + guard let slowmodeProgressLayer = self.slowmodeProgressLayer, let slowmodeProgressTimestamp = self.slowmodeProgressTimestamp else { + return + } + + let timestamp = Date().timeIntervalSince1970 + let timeout = max(0.0, Double(slowmodeProgressTimestamp.timestamp) - timestamp) + let fraction = timeout / max(0.1, Double(slowmodeProgressTimestamp.duration)) + + slowmodeProgressLayer.strokeEnd = CGFloat(fraction) + } + public func updateAccessibility() { self.accessibilityTraits = .button if !self.micButton.alpha.isZero { diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index a40c0fe8dd..4a74571527 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -2308,6 +2308,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } } } + sendActionButtonsSize = self.sendActionButtons.updateLayout(size: CGSize(width: 40.0, height: minimalHeight), isMediaInputExpanded: isMediaInputExpanded, showTitle: showTitle, currentMessageEffectId: presentationInterfaceState.interfaceState.sendMessageEffect, transition: transition, interfaceState: presentationInterfaceState) mediaActionButtonsSize = self.mediaActionButtons.updateLayout(size: CGSize(width: 40.0, height: minimalHeight), isMediaInputExpanded: isMediaInputExpanded, showTitle: false, currentMessageEffectId: presentationInterfaceState.interfaceState.sendMessageEffect, transition: transition, interfaceState: presentationInterfaceState) } @@ -5600,6 +5601,9 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } public func frameForInputActionButton() -> CGRect? { + if !self.sendActionButtons.alpha.isZero && self.sendActionButtons.frame.minX < self.bounds.width { + return self.sendActionButtons.frame.insetBy(dx: 0.0, dy: -4.0).offsetBy(dx: -3.0, dy: 0.0) + } if !self.mediaActionButtons.alpha.isZero && self.mediaActionButtons.frame.minX < self.bounds.width { return self.mediaActionButtons.frame.insetBy(dx: 0.0, dy: -4.0).offsetBy(dx: -3.0, dy: 0.0) } diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 92c25d9349..5f4d8181d2 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -1371,7 +1371,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift index 25408482d8..7ea3af3a22 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/StickerAttachmentScreen.swift @@ -309,7 +309,7 @@ final class StickerAttachmentScreenComponent: Component { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break @@ -719,7 +719,7 @@ final class StickerAttachmentScreenComponent: Component { if installed { return .complete() } else { - return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) + return context.engine.stickers.addStickerPackInteractively(info: info._parse(), items: items) |> map { _ in return Void() } } case .fetching: break diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift index 546500c6f4..d65a39fad8 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextControllerExtractedPresentationNode.swift @@ -134,17 +134,18 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo private final class ItemContentNode: ASDisplayNode { let offsetContainerNode: ASDisplayNode var containingItem: ContextControllerTakeViewInfo.ContainingItem - + var animateClippingFromContentAreaInScreenSpace: CGRect? var storedGlobalFrame: CGRect? var storedGlobalBoundsFrame: CGRect? - + var presentationScale: CGFloat = 1.0 + init(containingItem: ContextControllerTakeViewInfo.ContainingItem) { self.offsetContainerNode = ASDisplayNode() self.containingItem = containingItem - + super.init() - + self.addSubnode(self.offsetContainerNode) } @@ -633,6 +634,21 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo } let contentNodeValue = ItemContentNode(containingItem: takeInfo.containingItem) contentNodeValue.animateClippingFromContentAreaInScreenSpace = takeInfo.contentAreaInScreenSpace + + // Mirror any ancestor scale on the source (e.g. a sheet's container transform) onto the offset + // container so the extracted contents render at the same visual size as in-place — without this + // they pop to 1:1 when reparented into the unscaled overlay window. + let sourceView = takeInfo.containingItem.view + let modeledWidth = sourceView.bounds.width + if modeledWidth > 0.001 { + let visualWidth = sourceView.convert(sourceView.bounds, to: nil).width + let detectedScale = visualWidth / modeledWidth + if abs(detectedScale - 1.0) > 0.001 { + contentNodeValue.presentationScale = detectedScale + contentNodeValue.offsetContainerNode.layer.transform = CATransform3DMakeScale(detectedScale, detectedScale, 1.0) + } + } + self.scrollNode.insertSubnode(contentNodeValue, aboveSubnode: self.actionsContainerNode) self.itemContentNode = contentNodeValue itemContentNode = contentNodeValue @@ -1165,6 +1181,13 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo if let contentNode = itemContentNode { var contentFrame = CGRect(origin: CGPoint(x: contentParentGlobalFrame.minX + contentRect.minX - contentNode.containingItem.contentRect.minX, y: contentRect.minY - contentNode.containingItem.contentRect.minY + contentVerticalOffset + additionalVisibleOffsetY), size: contentNode.containingItem.view.bounds.size) + // contentRect.minY was derived from storedGlobalFrame.maxY (visual) minus contentRect.height (modeled); + // when an ancestor scale is in effect those don't cancel cleanly, leaving a (1 - scale) * (cy + ch) + // residue that pulls the content upward. Add it back so the content lands at the source's visual Y. + if contentNode.presentationScale != 1.0 { + let cr = contentNode.containingItem.contentRect + contentFrame.origin.y += (1.0 - contentNode.presentationScale) * (cr.minY + cr.height) + } if case let .extracted(extracted) = self.source { if extracted.adjustContentHorizontally { contentFrame.origin.x = combinedActionsFrame.minX @@ -1542,6 +1565,12 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo switch result { case .default, .custom: animationInContentYDistance = currentContentLocalFrame.minY - currentContentScreenFrame.minY + // Same modeled-vs-visual mismatch as the static contentFrame compensation: contentRect.minY (used by + // currentContentLocalFrame) was derived with modeled height while the source-side reference uses visual + // height, leaving a `ch * (1 - scale)` residue that animates the content downward on dismiss. + if let contentNode = itemContentNode, contentNode.presentationScale != 1.0 { + animationInContentYDistance += contentNode.containingItem.contentRect.height * (1.0 - contentNode.presentationScale) + } case .dismissWithoutContent: animationInContentYDistance = 0.0 if let contentNode = itemContentNode { diff --git a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift index 82475ee6f2..01a8977756 100644 --- a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift @@ -339,24 +339,24 @@ public final class EmojiStatusSelectionController: ViewController { self.componentHost = ComponentView() self.componentShadowLayer = SimpleLayer() - self.componentShadowLayer.shadowOpacity = 0.12 + self.componentShadowLayer.shadowOpacity = 0.35 self.componentShadowLayer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor - self.componentShadowLayer.shadowOffset = CGSize(width: 0.0, height: 2.0) - self.componentShadowLayer.shadowRadius = 16.0 + self.componentShadowLayer.shadowOffset = CGSize(width: 0.0, height: 10.0) + self.componentShadowLayer.shadowRadius = 30.0 self.cloudLayer0 = SimpleLayer() self.cloudShadowLayer0 = SimpleLayer() - self.cloudShadowLayer0.shadowOpacity = 0.12 - self.cloudShadowLayer0.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor - self.cloudShadowLayer0.shadowOffset = CGSize(width: 0.0, height: 2.0) - self.cloudShadowLayer0.shadowRadius = 16.0 + self.cloudShadowLayer0.shadowOpacity = self.componentShadowLayer.shadowOpacity + self.cloudShadowLayer0.shadowColor = self.componentShadowLayer.shadowColor + self.cloudShadowLayer0.shadowOffset = self.componentShadowLayer.shadowOffset + self.cloudShadowLayer0.shadowRadius = self.componentShadowLayer.shadowRadius self.cloudLayer1 = SimpleLayer() self.cloudShadowLayer1 = SimpleLayer() - self.cloudShadowLayer1.shadowOpacity = 0.12 - self.cloudShadowLayer1.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor - self.cloudShadowLayer1.shadowOffset = CGSize(width: 0.0, height: 2.0) - self.cloudShadowLayer1.shadowRadius = 16.0 + self.cloudShadowLayer1.shadowOpacity = self.componentShadowLayer.shadowOpacity + self.cloudShadowLayer1.shadowColor = self.componentShadowLayer.shadowColor + self.cloudShadowLayer1.shadowOffset = self.componentShadowLayer.shadowOffset + self.cloudShadowLayer1.shadowRadius = self.componentShadowLayer.shadowRadius super.init() @@ -973,16 +973,12 @@ public final class EmojiStatusSelectionController: ViewController { if self.presentationData.theme.overallDarkAppearance { listBackgroundColor = self.presentationData.theme.list.itemBlocksBackgroundColor separatorColor = self.presentationData.theme.list.itemBlocksSeparatorColor - self.componentShadowLayer.shadowOpacity = 0.32 - self.cloudShadowLayer0.shadowOpacity = 0.32 - self.cloudShadowLayer1.shadowOpacity = 0.32 } else { listBackgroundColor = self.presentationData.theme.list.plainBackgroundColor separatorColor = self.presentationData.theme.list.itemPlainSeparatorColor.withMultipliedAlpha(0.5) - self.componentShadowLayer.shadowOpacity = 0.12 - self.cloudShadowLayer0.shadowOpacity = 0.12 - self.cloudShadowLayer1.shadowOpacity = 0.12 } + self.cloudShadowLayer0.shadowOpacity = self.componentShadowLayer.shadowOpacity + self.cloudShadowLayer1.shadowOpacity = self.componentShadowLayer.shadowOpacity self.cloudLayer0.backgroundColor = listBackgroundColor.cgColor self.cloudLayer1.backgroundColor = listBackgroundColor.cgColor diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift index df4dd2efbb..c7b08f6fa2 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentSignals.swift @@ -1241,6 +1241,9 @@ public extension EmojiPagerContentComponent { tintMode: tintMode ) case let .text(text): + if !areUnicodeEmojiEnabled { + continue + } resultItem = EmojiPagerContentComponent.Item( animationData: nil, content: .staticEmoji(text), diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift index d5378abeb1..77c53244df 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -298,6 +298,7 @@ public final class InteractiveTextNodeLayoutArguments { public let customTruncationToken: ((UIFont, Bool) -> NSAttributedString?)? public let expandedBlocks: Set public let computeCharacterRects: Bool + public let minWidth: CGFloat? public init( attributedString: NSAttributedString?, @@ -318,7 +319,8 @@ public final class InteractiveTextNodeLayoutArguments { displayContentsUnderSpoilers: Bool = false, customTruncationToken: ((UIFont, Bool) -> NSAttributedString?)? = nil, expandedBlocks: Set = Set(), - computeCharacterRects: Bool = false + computeCharacterRects: Bool = false, + minWidth: CGFloat? = nil ) { self.attributedString = attributedString self.backgroundColor = backgroundColor @@ -339,6 +341,7 @@ public final class InteractiveTextNodeLayoutArguments { self.customTruncationToken = customTruncationToken self.expandedBlocks = expandedBlocks self.computeCharacterRects = computeCharacterRects + self.minWidth = minWidth } public func withAttributedString(_ attributedString: NSAttributedString?) -> InteractiveTextNodeLayoutArguments { @@ -361,7 +364,8 @@ public final class InteractiveTextNodeLayoutArguments { displayContentsUnderSpoilers: self.displayContentsUnderSpoilers, customTruncationToken: self.customTruncationToken, expandedBlocks: self.expandedBlocks, - computeCharacterRects: self.computeCharacterRects + computeCharacterRects: self.computeCharacterRects, + minWidth: self.minWidth ) } } @@ -424,6 +428,7 @@ public final class InteractiveTextNodeLayout: NSObject { fileprivate let textStroke: (UIColor, CGFloat)? public let displayContentsUnderSpoilers: Bool fileprivate let expandedBlocks: Set + public let minWidth: CGFloat? fileprivate init( attributedString: NSAttributedString?, @@ -447,7 +452,8 @@ public final class InteractiveTextNodeLayout: NSObject { textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displayContentsUnderSpoilers: Bool, - expandedBlocks: Set + expandedBlocks: Set, + minWidth: CGFloat? ) { self.attributedString = attributedString self.maximumNumberOfLines = maximumNumberOfLines @@ -471,6 +477,7 @@ public final class InteractiveTextNodeLayout: NSObject { self.textStroke = textStroke self.displayContentsUnderSpoilers = displayContentsUnderSpoilers self.expandedBlocks = expandedBlocks + self.minWidth = minWidth } func withUpdatedDisplayContentsUnderSpoilers(_ displayContentsUnderSpoilers: Bool) -> InteractiveTextNodeLayout { @@ -496,7 +503,8 @@ public final class InteractiveTextNodeLayout: NSObject { textShadowBlur: self.textShadowBlur, textStroke: self.textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, - expandedBlocks: self.expandedBlocks + expandedBlocks: self.expandedBlocks, + minWidth: self.minWidth ) } @@ -1102,6 +1110,12 @@ public final class InteractiveTextNodeLayout: NSObject { } height += self.insets.top + self.insets.bottom + 2.0 + + if let minWidth = self.minWidth { + width = max(width, minWidth) + trailingLineWidth = max(trailingLineWidth, minWidth) + } + return TextNodeLayout.LayoutInfo( size: CGSize(width: width, height: ceil(height)), trailingLineWidth: trailingLineWidth @@ -1474,7 +1488,8 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn displayContentsUnderSpoilers: Bool, customTruncationToken: ((UIFont, Bool) -> NSAttributedString?)?, expandedBlocks: Set, - computeCharacterRects: Bool = false + computeCharacterRects: Bool = false, + minWidth: CGFloat? ) -> InteractiveTextNodeLayout { let blockQuoteLeftInset: CGFloat = 9.0 let blockQuoteRightInset: CGFloat = 0.0 @@ -2046,6 +2061,10 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn size.width += insets.left + insets.right size.height += insets.top + insets.bottom + if let minWidth { + size.width = max(size.width, minWidth) + } + return InteractiveTextNodeLayout( attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, @@ -2068,16 +2087,17 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, - expandedBlocks: expandedBlocks + expandedBlocks: expandedBlocks, + minWidth: minWidth ) } - static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displayContentsUnderSpoilers: Bool, customTruncationToken: ((UIFont, Bool) -> NSAttributedString?)?, expandedBlocks: Set, computeCharacterRects: Bool = false) -> InteractiveTextNodeLayout { + static func calculateLayout(attributedString: NSAttributedString?, minimumNumberOfLines: Int, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, verticalAlignment: TextVerticalAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, lineColor: UIColor?, textShadowColor: UIColor?, textShadowBlur: CGFloat?, textStroke: (UIColor, CGFloat)?, displayContentsUnderSpoilers: Bool, customTruncationToken: ((UIFont, Bool) -> NSAttributedString?)?, expandedBlocks: Set, computeCharacterRects: Bool = false, minWidth: CGFloat? = nil) -> InteractiveTextNodeLayout { guard let attributedString else { - return InteractiveTextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, segments: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, expandedBlocks: expandedBlocks) + return InteractiveTextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, explicitAlignment: alignment, resolvedAlignment: alignment, verticalAlignment: verticalAlignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), rawTextSize: CGSize(), truncated: false, firstLineOffset: 0.0, segments: [], backgroundColor: backgroundColor, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, expandedBlocks: expandedBlocks, minWidth: minWidth) } - return calculateLayoutV2(attributedString: attributedString, minimumNumberOfLines: minimumNumberOfLines, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, alignment: alignment, verticalAlignment: verticalAlignment, lineSpacingFactor: lineSpacingFactor, cutout: cutout, insets: insets, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, customTruncationToken: customTruncationToken, expandedBlocks: expandedBlocks, computeCharacterRects: computeCharacterRects) + return calculateLayoutV2(attributedString: attributedString, minimumNumberOfLines: minimumNumberOfLines, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, backgroundColor: backgroundColor, constrainedSize: constrainedSize, alignment: alignment, verticalAlignment: verticalAlignment, lineSpacingFactor: lineSpacingFactor, cutout: cutout, insets: insets, lineColor: lineColor, textShadowColor: textShadowColor, textShadowBlur: textShadowBlur, textStroke: textStroke, displayContentsUnderSpoilers: displayContentsUnderSpoilers, customTruncationToken: customTruncationToken, expandedBlocks: expandedBlocks, computeCharacterRects: computeCharacterRects, minWidth: minWidth) } private func updateContentItems(arguments: ApplyArguments) { @@ -2222,7 +2242,7 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn return { arguments in var layout: InteractiveTextNodeLayout - if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) && existingLayout.expandedBlocks == arguments.expandedBlocks { + if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.explicitAlignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) && existingLayout.expandedBlocks == arguments.expandedBlocks && existingLayout.minWidth == arguments.minWidth { let stringMatch: Bool var colorMatch: Bool = true @@ -2250,10 +2270,10 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn layout = layout.withUpdatedDisplayContentsUnderSpoilers(arguments.displayContentsUnderSpoilers) } } else { - layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, expandedBlocks: arguments.expandedBlocks, computeCharacterRects: arguments.computeCharacterRects) + layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, expandedBlocks: arguments.expandedBlocks, computeCharacterRects: arguments.computeCharacterRects, minWidth: arguments.minWidth) } } else { - layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, expandedBlocks: arguments.expandedBlocks, computeCharacterRects: arguments.computeCharacterRects) + layout = InteractiveTextNode.calculateLayout(attributedString: arguments.attributedString, minimumNumberOfLines: arguments.minimumNumberOfLines, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, verticalAlignment: arguments.verticalAlignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets, lineColor: arguments.lineColor, textShadowColor: arguments.textShadowColor, textShadowBlur: arguments.textShadowBlur, textStroke: arguments.textStroke, displayContentsUnderSpoilers: arguments.displayContentsUnderSpoilers, customTruncationToken: arguments.customTruncationToken, expandedBlocks: arguments.expandedBlocks, computeCharacterRects: arguments.computeCharacterRects, minWidth: arguments.minWidth) } let node = maybeNode ?? InteractiveTextNode() diff --git a/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift b/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift index 6d0a5d7c57..9d217ebf3f 100644 --- a/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift +++ b/submodules/TelegramUI/Components/NavigationBarImpl/Sources/NavigationBarImpl.swift @@ -1073,17 +1073,18 @@ public final class NavigationBarImpl: ASDisplayNode, NavigationBar { } if self.titleNode.view.superview != nil { - let titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, size.width - max(leftTitleInset, rightTitleInset) * 2.0), height: nominalHeight)) + var transition = transition + if self.titleNode.frame.width.isZero { + transition = .immediate + } + self.titleNode.alpha = 1.0 - do { - var transition = transition - if self.titleNode.frame.width.isZero { - transition = .immediate - } - self.titleNode.alpha = 1.0 - - let titleOffset: CGFloat = 0.0 - transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: contentVerticalOrigin + titleOffset + floorToScreenPixels((nominalHeight - titleSize.height) / 2.0)), size: titleSize)) + let titleSize = self.titleNode.updateLayout(CGSize(width: max(1.0, size.width - leftTitleInset - rightTitleInset), height: nominalHeight)) + + if titleSize.width <= size.width - max(leftTitleInset, rightTitleInset) * 2.0 { + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: contentVerticalOrigin + floorToScreenPixels((nominalHeight - titleSize.height) / 2.0)), size: titleSize)) + } else { + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftTitleInset + floor((size.width - leftTitleInset - rightTitleInset - titleSize.width) / 2.0), y: contentVerticalOrigin + floorToScreenPixels((nominalHeight - titleSize.height) / 2.0)), size: titleSize)) } } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift index fdc26f3789..e767664904 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift @@ -1350,7 +1350,10 @@ final class PeerInfoHeaderNode: ASDisplayNode { let textSideInset: CGFloat = 36.0 let expandedAvatarHeight: CGFloat = expandedAvatarListSize.height - let titleConstrainedSize = CGSize(width: width - textSideInset * 2.0 - (isPremium || isVerified || isFake ? 20.0 : 0.0), height: .greatestFiniteMagnitude) + var titleConstrainedSize = CGSize(width: width - textSideInset * 2.0 - (isPremium || isVerified || isFake ? 20.0 : 0.0), height: .greatestFiniteMagnitude) + if self.navigationButtonContainer.rightButtonNodes.count > 1 { + titleConstrainedSize.width -= 60.0 + } let titleNodeLayout = self.titleNode.updateLayout(text: titleStringText, states: [ TitleNodeStateRegular: MultiScaleTextState(attributes: titleAttributes, constrainedSize: titleConstrainedSize), diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift index be385042ec..d7529e9a3b 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift @@ -470,7 +470,8 @@ private final class ItemComponent: Component { guard let component = self.component else { return } - let containerFrame = CGRect(origin: CGPoint(x: floor((size.width - measuredSize.width) * 0.5), y: floor((measuredSize.height - size.height) * 0.5)), size: measuredSize) + + let containerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - measuredSize.width) * 0.5), y: floor((measuredSize.height - size.height) * 0.5)), size: measuredSize) let contentRect = CGRect(origin: CGPoint(x: 0.0, y: -5.0 - 4.0), size: CGSize(width: size.width + 0.0, height: size.height + 5.0 + 3.0 + 6.0)) transition.setFrame(view: self.backgroundContainer, frame: contentRect) self.backgroundContainer.update(size: contentRect.size, isDark: component.theme.overallDarkAppearance, transition: transition) diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 5b62ef7ea5..6032f669d1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -8740,6 +8740,13 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G getAnimatedTransitionSource: ((String) -> UIView?)? = nil, completion: @escaping () -> Void = {} ) { + var animateTransition = true + if let validLayout = self.chatDisplayNode.validLayout?.0 { + if validLayout.metrics.widthClass != .compact { + animateTransition = false + } + } + self.enqueueMediaMessageDisposable.set((legacyAssetPickerEnqueueMessages(context: self.context, account: self.context.account, signals: signals!, originalMediaReference: originalMediaReference) |> deliverOnMainQueue).startStrict(next: { [weak self] items in guard let strongSelf = self else { @@ -8766,6 +8773,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if shouldDivert { skipAddingTransitions = true } + if !animateTransition { + skipAddingTransitions = true + } for item in items { var message = item.message diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 4f1e87ba3f..68f52b510e 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -4058,8 +4058,8 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { func frameForInputActionButton() -> CGRect? { if let textInputPanelNode = self.textInputPanelNode, self.inputPanelNode === textInputPanelNode { - return textInputPanelNode.frameForInputActionButton().flatMap { - return $0.offsetBy(dx: textInputPanelNode.frame.minX, dy: textInputPanelNode.frame.minY) + return textInputPanelNode.frameForInputActionButton().flatMap { rect in + return self.view.convert(rect, from: textInputPanelNode.view) } } return nil diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index 4c254eeabc..76fcf7bc4b 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -18,6 +18,9 @@ import ChatControllerInteraction import ChatContextResultPeekContent import ChatInputContextPanelNode import BatchVideoRendering +import GlassBackgroundComponent +import ComponentFlow +import ComponentDisplayAdapters private struct ChatContextResultStableId: Hashable { let result: ChatContextResult @@ -82,6 +85,9 @@ private func preparedTransition(from fromEntries: [HorizontalListContextResultsC } final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputContextPanelNode { + private let backgroundContainerView: GlassBackgroundContainerView + private let backgroundView: GlassBackgroundView + private let listClippingView: UIView private let listView: ListView private var currentExternalResults: ChatContextResultCollection? private var currentProcessedResults: ChatContextResultCollection? @@ -95,9 +101,14 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont private let batchVideoContext: QueueLocalObject override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { + self.backgroundContainerView = GlassBackgroundContainerView() + self.backgroundView = GlassBackgroundView() + self.backgroundContainerView.contentView.addSubview(self.backgroundView) + self.listClippingView = UIView() + self.listClippingView.clipsToBounds = true + self.listView = ListViewImpl() - self.listView.isOpaque = true - self.listView.backgroundColor = theme.list.plainBackgroundColor + self.listView.isOpaque = false self.listView.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) self.listView.isHidden = true self.listView.accessibilityPageScrolledString = { row, count in @@ -111,9 +122,12 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false - self.clipsToBounds = true + self.clipsToBounds = false + self.layer.allowsGroupOpacity = true - self.addSubnode(self.listView) + self.view.addSubview(self.backgroundContainerView) + self.listClippingView.addSubview(self.listView.view) + self.backgroundView.contentView.addSubview(self.listClippingView) self.listView.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in if let strongSelf = self, let state = opaqueTransactionState as? HorizontalListContextResultsOpaqueState { @@ -361,12 +375,26 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont override func updateLayout(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { let listHeight: CGFloat = 105.0 + let sideInset: CGFloat = 8.0 + let innerInset: CGFloat = 4.0 + let cornerRadius: CGFloat = 8.0 + let innerRadius: CGFloat = cornerRadius - innerInset - self.listView.bounds = CGRect(x: 0.0, y: 0.0, width: listHeight, height: size.width) + let listFrame = CGRect(x: sideInset, y: size.height - bottomInset - 8.0 - listHeight, width: size.width - sideInset * 2.0, height: listHeight) + let transformedListFrame = CGSize(width: listFrame.height, height: listFrame.width).centered(in: listFrame) + self.listView.bounds = CGRect(origin: CGPoint(), size: transformedListFrame.size) + transition.updatePosition(node: self.listView, position: CGRect(origin: CGPoint(x: -innerInset, y: -innerInset), size: listFrame.size).center) - //transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) + transition.updateFrame(view: self.listClippingView, frame: CGRect(origin: CGPoint(), size: listFrame.size).insetBy(dx: innerInset, dy: innerInset)) + self.listClippingView.layer.cornerRadius = innerRadius - transition.updatePosition(node: self.listView, position: CGPoint(x: size.width / 2.0, y: size.height - bottomInset - 8.0 - listHeight / 2.0)) + let backgroundContainerInset: CGFloat = 32.0 + let backgroundContainerFrame = listFrame.insetBy(dx: -backgroundContainerInset, dy: -backgroundContainerInset) + transition.updateFrame(view: self.backgroundContainerView, frame: backgroundContainerFrame) + self.backgroundContainerView.update(size: backgroundContainerFrame.size, isDark: interfaceState.theme.overallDarkAppearance, transition: ComponentTransition(transition)) + + transition.updateFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: listFrame.size).offsetBy(dx: backgroundContainerInset, dy: backgroundContainerInset)) + self.backgroundView.update(size: listFrame.size, cornerRadius: cornerRadius, isDark: interfaceState.theme.overallDarkAppearance, tintColor: .init(kind: .panel), transition: ComponentTransition(transition)) var insets = UIEdgeInsets() insets.top = leftInset @@ -391,22 +419,18 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont } override func animateOut(completion: @escaping () -> Void) { - /*let position = self.listView.layer.position - self.listView.layer.animatePosition(from: position, to: CGPoint(x: position.x, y: position.y + self.listView.bounds.size.width), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - completion() - })*/ - self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { _ in + ComponentTransition.easeInOut(duration: 0.3).setAlpha(view: self.backgroundContainerView, alpha: 0.01, completion: { _ in completion() }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - let listViewBounds = self.listView.bounds - let listViewPosition = self.listView.position - let listViewFrame = CGRect(origin: CGPoint(x: listViewPosition.x - listViewBounds.height / 2.0, y: listViewPosition.y - listViewBounds.width / 2.0), size: CGSize(width: listViewBounds.height, height: listViewBounds.width)) - if !listViewFrame.contains(point) { + guard let result = super.hitTest(point, with: event) else { return nil } - return super.hitTest(point, with: event) + if result === self.view { + return nil + } + return result } } diff --git a/third-party/td/build-td-bazel.sh b/third-party/td/build-td-bazel.sh index a977bbb10d..6ccc056e74 100755 --- a/third-party/td/build-td-bazel.sh +++ b/third-party/td/build-td-bazel.sh @@ -29,15 +29,16 @@ cd .. if [ "$ARCH" = "arm64" ]; then IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/iPhoneOS.platform" IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/iPhoneOS*.sdk) - export CFLAGS="-arch arm64 --target=arm64-apple-ios13.0 -miphoneos-version-min=13.0" + export CFLAGS="-arch arm64 --target=arm64-apple-ios13.0 -miphoneos-version-min=13.0 -w" elif [ "$ARCH" = "sim_arm64" ]; then IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/iPhoneSimulator.platform" IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/iPhoneSimulator*.sdk) - export CFLAGS="-arch arm64 --target=arm64-apple-ios13.0-simulator -miphonesimulator-version-min=13.0" + export CFLAGS="-arch arm64 --target=arm64-apple-ios13.0-simulator -miphonesimulator-version-min=13.0 -w" else echo "Unsupported architecture $ARCH" exit 1 fi +export CXXFLAGS="$CFLAGS" # Common build steps mkdir build From bc5c462eea6d392dd49763f49dbd9e281ab15bd9 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 17:00:16 +0400 Subject: [PATCH 07/69] Fix rtl reveal --- .../Sources/InteractiveTextComponent.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift index 77c53244df..25153b3fe8 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -2870,13 +2870,28 @@ final class TextContentItemLayer: SimpleLayer { let revealCount = min(characterRects.count, remainingCharacters) var revealedWidth: CGFloat = 0.0 - for j in 0 ..< revealCount { - let rect = characterRects[j] - if !rect.isEmpty { - revealedWidth = max(revealedWidth, rect.maxX) + if line.isRTL { + // Logical index 0 is the visually rightmost glyph in an RTL line, + // so the revealed extent grows leftward from the right edge. + var minX: CGFloat = .greatestFiniteMagnitude + for j in 0 ..< revealCount { + let rect = characterRects[j] + if !rect.isEmpty { + minX = min(minX, rect.minX) + } } + if minX != .greatestFiniteMagnitude { + revealedWidth = ceil(lineFrame.width - minX) + } + } else { + for j in 0 ..< revealCount { + let rect = characterRects[j] + if !rect.isEmpty { + revealedWidth = max(revealedWidth, rect.maxX) + } + } + revealedWidth = ceil(revealedWidth) } - revealedWidth = ceil(revealedWidth) remainingCharacters -= characterRects.count let isFull = remainingCharacters >= 0 From d83734eb461fe98aebfcf07cb31a3e4620eb477b Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 19:00:04 +0400 Subject: [PATCH 08/69] Various improvements --- .../Sources/DebugController.swift | 54 +- .../Sources/InstantPageLayout.swift | 4 +- .../Sources/InstantPageTileNode.swift | 9 +- .../Chat/ChatMessageBubbleItemNode/BUILD | 1 + .../Sources/ChatMessageBubbleItemNode.swift | 27 +- .../BUILD | 27 + ...ChatMessageRichDataBubbleContentNode.swift | 478 ++++++++++++++++++ .../Sources/TextStyleEditScreen.swift | 9 +- .../Sources/ExperimentalUISettings.swift | 10 +- 9 files changed, 587 insertions(+), 32 deletions(-) create mode 100644 submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD create mode 100644 submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index aa97e6e2c7..76a88cf387 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -98,6 +98,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case fakeGlass(Bool) case forceClearGlass(Bool) case debugRipple(Bool) + case debugRichText(Bool) case browserExperiment(Bool) case allForumsHaveTabs(Bool) case enableReactionOverrides(Bool) @@ -137,7 +138,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { return DebugControllerSection.web.rawValue case .keepChatNavigationStack, .skipReadHistory, .alwaysDisplayTyping, .debugRatingLayout, .crashOnSlowQueries, .crashOnMemoryPressure: return DebugControllerSection.experiments.rawValue - case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .forceClearGlass, .debugRipple, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .pwa, .enableLocalTranslation: + case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .compressedEmojiCache, .storiesJpegExperiment, .checkSerializedData, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .fakeGlass, .forceClearGlass, .debugRipple, .debugRichText, .browserExperiment, .allForumsHaveTabs, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .liveStreamV2, .experimentalCallMute, .playerV2, .devRequests, .enableUpdates, .pwa, .enableLocalTranslation: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -234,44 +235,46 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 40 case .debugRipple: return 41 - case .browserExperiment: + case .debugRichText: return 42 - case .allForumsHaveTabs: + case .browserExperiment: return 43 - case .enableReactionOverrides: + case .allForumsHaveTabs: return 44 - case .restorePurchases: + case .enableReactionOverrides: return 45 - case .logTranslationRecognition: + case .restorePurchases: return 46 - case .resetTranslationStates: + case .logTranslationRecognition: return 47 - case .compressedEmojiCache: + case .resetTranslationStates: return 48 - case .storiesJpegExperiment: + case .compressedEmojiCache: return 49 - case .disableReloginTokens: + case .storiesJpegExperiment: return 50 - case .checkSerializedData: + case .disableReloginTokens: return 51 - case .enableQuickReactionSwitch: + case .checkSerializedData: return 52 - case .liveStreamV2: + case .enableQuickReactionSwitch: return 53 - case .experimentalCallMute: + case .liveStreamV2: return 54 - case .playerV2: + case .experimentalCallMute: return 55 - case .devRequests: + case .playerV2: return 56 - case .pwa: + case .devRequests: return 57 - case .enableLocalTranslation: + case .pwa: return 58 - case .enableUpdates: + case .enableLocalTranslation: return 59 + case .enableUpdates: + return 60 case let .preferredVideoCodec(index, _, _, _): - return 60 + index + return 61 + index case .disableVideoAspectScaling: return 100 case .enableNetworkFramework: @@ -1305,6 +1308,16 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) + case let .debugRichText(value): + return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Debug Text", value: value, sectionId: self.section, style: .blocks, updated: { value in + let _ = arguments.sharedContext.accountManager.transaction ({ transaction in + transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in + var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings + settings.debugRichText = value + return PreferencesEntry(settings) + }) + }).start() + }) case let .browserExperiment(value): return ItemListSwitchItem(presentationData: presentationData, systemStyle: .glass, title: "Inline UI", value: value, sectionId: self.section, style: .blocks, updated: { value in let _ = arguments.sharedContext.accountManager.transaction ({ transaction in @@ -1583,6 +1596,7 @@ private func debugControllerEntries(context: AccountContext?, sharedContext: Sha entries.append(.fakeGlass(experimentalSettings.fakeGlass)) entries.append(.forceClearGlass(experimentalSettings.forceClearGlass)) entries.append(.debugRipple(experimentalSettings.debugRipple)) + entries.append(.debugRichText(experimentalSettings.debugRichText)) #if DEBUG entries.append(.browserExperiment(experimentalSettings.browserExperiment)) #else diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 937d04c6b4..39baa17a74 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -1046,7 +1046,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } } -public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil) -> InstantPageLayout { +public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true) -> InstantPageLayout { var maybeLoadedContent: TelegramMediaWebpageLoadedContent? if case let .Loaded(content) = webPage.content { maybeLoadedContent = content @@ -1088,7 +1088,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil) contentSize.height += closingSpacing - if webPage.webpageId.id != 0 { + if webPage.webpageId.id != 0 && addFeedback { let feedbackItem = InstantPageFeedbackItem(frame: CGRect(x: 0.0, y: contentSize.height, width: boundingWidth, height: 40.0), webPage: webPage) contentSize.height += feedbackItem.frame.height items.append(feedbackItem) diff --git a/submodules/InstantPageUI/Sources/InstantPageTileNode.swift b/submodules/InstantPageUI/Sources/InstantPageTileNode.swift index 3ed1a30426..2da701bd59 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTileNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTileNode.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import AsyncDisplayKit +import Display private final class InstantPageTileNodeParameters: NSObject { let tile: InstantPageTile @@ -45,9 +46,11 @@ public final class InstantPageTileNode: ASDisplayNode { if let parameters = parameters as? InstantPageTileNodeParameters { if !isRasterizing { - context.setBlendMode(.copy) - context.setFillColor(parameters.backgroundColor.cgColor) - context.fill(bounds) + if !parameters.backgroundColor.alpha.isZero { + context.setBlendMode(.copy) + context.setFillColor(parameters.backgroundColor.cgColor) + context.fill(bounds) + } } parameters.tile.draw(context: context) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD index 21cd7ce809..a739a9a4e9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/BUILD @@ -96,6 +96,7 @@ swift_library( "//submodules/AvatarNode", "//submodules/TelegramUI/Components/Chat/ChatMessageSuggestedPostInfoNode", "//submodules/TelegramUI/Components/PremiumAlertController", + "//submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 8fb75e72b5..60c7f868a5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -35,6 +35,7 @@ import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ChatHistoryEntry import ChatMessageTextBubbleContentNode +import ChatMessageRichDataBubbleContentNode import ChatMessageItemCommon import ChatMessageReplyInfoNode import ChatMessageCallBubbleContentNode @@ -382,7 +383,11 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ if let attribute = message.attributes.first(where: { $0 is WebpagePreviewMessageAttribute }) as? WebpagePreviewMessageAttribute, attribute.leadingPreview { result.insert((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default)), at: addedPriceInfo ? 1 : 0) } else { - result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + if content.instantPage != nil && item.context.sharedContext.immediateExperimentalUISettings.debugRichText { + result.append((message, ChatMessageRichDataBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + } else { + result.append((message, ChatMessageWebpageBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .text, neighborSpacing: .default))) + } } needReactions = false } @@ -1620,6 +1625,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI var allowFullWidth = false let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId + + var isInlinePage = false + if item.context.sharedContext.immediateExperimentalUISettings.debugRichText, let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, content.instantPage != nil { + allowFullWidth = true + isInlinePage = true + } do { let peerId = chatLocationPeerId @@ -1888,6 +1899,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if let subject = item.associatedData.subject, case .messageOptions = subject { needsShareButton = false } + + if isInlinePage { + needsShareButton = false + } var tmpWidth: CGFloat if allowFullWidth { @@ -1895,7 +1910,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if (needsShareButton && !isSidePanelOpen) || isAd { tmpWidth -= 45.0 } else { - tmpWidth -= 4.0 + tmpWidth -= 3.0 } } else { tmpWidth = layoutConstants.bubble.maximumWidthFill.widthFor(baseWidth) @@ -2262,7 +2277,11 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI bubbleReactions = ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: []) } if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview { - bottomNodeMergeStatus = .Right + if incoming { + bottomNodeMergeStatus = .Both + } else { + bottomNodeMergeStatus = .Right + } } var currentCredibilityIcon: (EmojiStatusComponent.Content, UIColor?)? @@ -7348,7 +7367,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } for contentNode in self.contentNodes { - if contentNode is ChatMessageMediaBubbleContentNode || contentNode is ChatMessageGiftBubbleContentNode || contentNode is ChatMessageWebpageBubbleContentNode || contentNode is ChatMessageInvoiceBubbleContentNode || contentNode is ChatMessageGameBubbleContentNode || contentNode is ChatMessageInstantVideoBubbleContentNode { + if contentNode is ChatMessageMediaBubbleContentNode || contentNode is ChatMessageGiftBubbleContentNode || contentNode is ChatMessageWebpageBubbleContentNode || contentNode is ChatMessageInvoiceBubbleContentNode || contentNode is ChatMessageGameBubbleContentNode || contentNode is ChatMessageInstantVideoBubbleContentNode || contentNode is ChatMessageRichDataBubbleContentNode { contentNode.visibility = mapVisibility(effectiveMediaVisibility, boundsSize: self.bounds.size, insets: self.insets, to: contentNode) } else { contentNode.visibility = mapVisibility(effectiveVisibility, boundsSize: self.bounds.size, insets: self.insets, to: contentNode) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD new file mode 100644 index 0000000000..7c9d63aaa9 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -0,0 +1,27 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "ChatMessageRichDataBubbleContentNode", + module_name = "ChatMessageRichDataBubbleContentNode", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/InstantPageUI", + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUIPreferences", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift new file mode 100644 index 0000000000..dbb3525d44 --- /dev/null +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -0,0 +1,478 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import SwiftSignalKit +import AccountContext +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import InstantPageUI +import TelegramUIPreferences + +public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode { + public final class ContainerNode: ASDisplayNode { + } + + private let containerNode: ContainerNode + private var currentLayoutTiles: [InstantPageTile] = [] + private var visibleTiles: [Int: InstantPageTileNode] = [:] + private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] + private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? + private var distanceThresholdGroupCount: [Int: Int] = [:] + private var currentLayoutItemsWithNodes: [InstantPageItem] = [] + private var currentExpandedDetails: [Int : Bool]? + + override public var visibility: ListViewItemNodeVisibility { + didSet { + if oldValue != self.visibility { + self.updateVisibility() + } + } + } + + required public init() { + self.containerNode = ContainerNode() + self.containerNode.clipsToBounds = true + + super.init() + + self.addSubnode(self.containerNode) + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let currentPageLayout = self.currentPageLayout + let previousCurrentLayoutTiles = self.currentLayoutTiles + + return { [weak self] item, layoutConstants, _, _, _, _ in + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) + + return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in + let suggestedBoundingWidth: CGFloat = constrainedSize.width + + return (suggestedBoundingWidth, { boundingWidth in + var boundingSize = CGSize(width: boundingWidth, height: 0.0) + + var pageLayout: InstantPageLayout? + var currentLayoutTiles: [InstantPageTile] = [] + + if let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, let instantPage = content.instantPage { + if let current = currentPageLayout, current.boundingWidth == boundingSize.width { + pageLayout = current.layout + currentLayoutTiles = previousCurrentLayoutTiles + } else { + let pageTheme = instantPageThemeForType(item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( + themeType: item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, + fontSize: .standard, + forceSerif: false, + autoNightMode: false, + ignoreAutoNightModeUntil: 0 + )) + pageLayout = instantPageLayoutForWebPage(webpage, instantPage: instantPage._parse(), userLocation: .other, boundingWidth: boundingWidth - 2.0, safeInset: 0.0, strings: item.presentationData.strings, theme: pageTheme, dateTimeFormat: item.presentationData.dateTimeFormat, webEmbedHeights: [:], addFeedback: false) + if let pageLayout { + currentLayoutTiles = instantPageTilesFromLayout(pageLayout, boundingWidth: boundingWidth) + } + } + } + + if let pageLayout { + boundingSize.height = pageLayout.contentSize.height + 2.0 + } + + return (boundingSize, { animation, synchronousLoads, itemApply in + guard let self else { + return + } + self.item = item + + self.containerNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingSize.width - 2.0, height: boundingSize.height - 2.0)) + + if let pageLayout { + self.currentPageLayout = (boundingSize.width, pageLayout) + self.currentLayoutTiles = currentLayoutTiles + + var distanceThresholdGroupCount: [Int : Int] = [:] + + for item in pageLayout.items { + if item.wantsNode { + self.currentLayoutItemsWithNodes.append(item) + + if let group = item.distanceThresholdGroup() { + let count: Int + if let currentCount = distanceThresholdGroupCount[Int(group)] { + count = currentCount + } else { + count = 0 + } + distanceThresholdGroupCount[Int(group)] = count + 1 + } + } + } + + self.distanceThresholdGroupCount = distanceThresholdGroupCount + } else { + self.currentPageLayout = nil + self.currentLayoutTiles = [] + self.distanceThresholdGroupCount = [:] + } + + self.updateVisibility() + }) + }) + }) + } + } + + private func effectiveFrameForTile(_ tile: InstantPageTile) -> CGRect { + let layoutOrigin = tile.frame.origin + let origin = layoutOrigin + return CGRect(origin: origin, size: tile.frame.size) + } + + private func updateVisibility() { + switch self.visibility { + case .none: + self.updateVisibleItems(visibleBounds: CGRect(), animated: false) + case let .visible(_, subRect): + self.updateVisibleItems(visibleBounds: subRect, animated: false) + } + } + + private func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) { + guard let messageItem = self.item else { + return + } + let pageTheme = instantPageThemeForType(messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( + themeType: messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, + fontSize: .standard, + forceSerif: false, + autoNightMode: false, + ignoreAutoNightModeUntil: 0 + )) + let sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .otherPrivate) + + var visibleTileIndices = Set() + var visibleItemIndices = Set() + + var topNode: ASDisplayNode? + let topTileNode = topNode + if let containerSubnodes = self.containerNode.subnodes { + for node in containerSubnodes.reversed() { + if let node = node as? InstantPageTileNode { + topNode = node + break + } + } + } + + var collapseOffset: CGFloat = 0.0 + collapseOffset = 0.0 + let transition: ContainedViewLayoutTransition + if animated { + transition = .animated(duration: 0.3, curve: .spring) + } else { + transition = .immediate + } + + var itemIndex = -1 + var embedIndex = -1 + var detailsIndex = -1 + + var previousDetailsNode: InstantPageDetailsNode? + + for item in self.currentLayoutItemsWithNodes { + itemIndex += 1 + if item is InstantPageWebEmbedItem { + embedIndex += 1 + } + if let imageItem = item as? InstantPageImageItem, case .webpage = imageItem.media.media { + embedIndex += 1 + } + if item is InstantPageDetailsItem { + detailsIndex += 1 + } + + var itemThreshold: CGFloat = 0.0 + if let group = item.distanceThresholdGroup() { + var count: Int = 0 + if let currentCount = self.distanceThresholdGroupCount[group] { + count = currentCount + } + itemThreshold = item.distanceThresholdWithGroupCount(count) + } + + let itemFrame = item.frame.offsetBy(dx: 0.0, dy: -collapseOffset) + var thresholdedItemFrame = itemFrame + thresholdedItemFrame.origin.y -= itemThreshold + thresholdedItemFrame.size.height += itemThreshold * 2.0 + + if visibleBounds.intersects(thresholdedItemFrame) { + visibleItemIndices.insert(itemIndex) + + var itemNode = self.visibleItemsWithNodes[itemIndex] + if let currentItemNode = itemNode { + if !item.matchesNode(currentItemNode) { + currentItemNode.removeFromSupernode() + self.visibleItemsWithNodes.removeValue(forKey: itemIndex) + itemNode = nil + } + } + + if itemNode == nil { + let itemIndex = itemIndex + //let embedIndex = embedIndex + //let detailsIndex = detailsIndex + if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { [weak self] media in + let _ = self + //self?.openMedia(media) + }, longPressMedia: { [weak self] media in + //self?.longPressMedia(media) + let _ = self + }, activatePinchPreview: { [weak self] sourceNode in + /*guard let strongSelf = self, let controller = strongSelf.controller else { + return + } + let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { + guard let strongSelf = self else { + return CGRect() + } + + let localRect = CGRect(origin: CGPoint(x: 0.0, y: strongSelf.navigationBar.frame.maxY), size: CGSize(width: strongSelf.bounds.width, height: strongSelf.bounds.height - strongSelf.navigationBar.frame.maxY)) + return strongSelf.view.convert(localRect, to: nil) + }) + controller.window?.presentInGlobalOverlay(pinchController)*/ + let _ = self + }, pinchPreviewFinished: { [weak self] itemNode in + /*guard let strongSelf = self else { + return + } + for (_, listItemNode) in strongSelf.visibleItemsWithNodes { + if let listItemNode = listItemNode as? InstantPagePeerReferenceNode { + if listItemNode.frame.intersects(itemNode.frame) && listItemNode.frame.maxY <= itemNode.frame.maxY + 2.0 { + listItemNode.layer.animateAlpha(from: 0.0, to: listItemNode.alpha, duration: 0.25) + break + } + } + }*/ + let _ = self + }, openPeer: { [weak self] peerId in + let _ = self + //self?.openPeer(peerId) + }, openUrl: { [weak self] url in + let _ = self + //self?.openUrl(url) + }, updateWebEmbedHeight: { [weak self] height in + let _ = self + //self?.updateWebEmbedHeight(embedIndex, height) + }, updateDetailsExpanded: { [weak self] expanded in + let _ = self + //self?.updateDetailsExpanded(detailsIndex, expanded) + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { + newNode.frame = itemFrame + newNode.updateLayout(size: itemFrame.size, transition: transition) + if let topNode = topNode { + self.containerNode.insertSubnode(newNode, aboveSubnode: topNode) + } else { + self.containerNode.insertSubnode(newNode, at: 0) + } + topNode = newNode + self.visibleItemsWithNodes[itemIndex] = newNode + itemNode = newNode + + if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.requestLayoutUpdate = { [weak self] animated in + let _ = self + /*if let strongSelf = self { + strongSelf.updateVisibleItems(visibleBounds: strongSelf.scrollNode.view.bounds, animated: animated) + }*/ + } + + if let previousDetailsNode = previousDetailsNode { + if itemNode.frame.minY - previousDetailsNode.frame.maxY < 1.0 { + itemNode.previousNode = previousDetailsNode + } + } + previousDetailsNode = itemNode + } + } + } else { + if let itemNode = itemNode, itemNode.frame != itemFrame { + transition.updateFrame(node: itemNode, frame: itemFrame) + itemNode.updateLayout(size: itemFrame.size, transition: transition) + } + } + + if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated) + } + } + } + + topNode = topTileNode + + var tileIndex = -1 + for tile in self.currentLayoutTiles { + tileIndex += 1 + + let tileFrame = effectiveFrameForTile(tile) + var tileVisibleFrame = tileFrame + tileVisibleFrame.origin.y -= 400.0 + tileVisibleFrame.size.height += 400.0 * 2.0 + if tileVisibleFrame.intersects(visibleBounds) { + visibleTileIndices.insert(tileIndex) + + if self.visibleTiles[tileIndex] == nil { + let tileNode = InstantPageTileNode(tile: tile, backgroundColor: .clear) + tileNode.frame = tileFrame + if let topNode = topNode { + self.containerNode.insertSubnode(tileNode, aboveSubnode: topNode) + } else { + self.containerNode.insertSubnode(tileNode, at: 0) + } + topNode = tileNode + self.visibleTiles[tileIndex] = tileNode + } else { + if let tileNode = self.visibleTiles[tileIndex] { + tileNode.update(tile: tile, backgroundColor: .clear) + if tileNode.frame != tileFrame { + transition.updateFrame(node: tileNode, frame: tileFrame) + } + } + } + } + } + + var removeTileIndices: [Int] = [] + for (index, tileNode) in self.visibleTiles { + if !visibleTileIndices.contains(index) { + removeTileIndices.append(index) + tileNode.removeFromSupernode() + } + } + for index in removeTileIndices { + self.visibleTiles.removeValue(forKey: index) + } + + var removeItemIndices: [Int] = [] + for (index, itemNode) in self.visibleItemsWithNodes { + if !visibleItemIndices.contains(index) { + removeItemIndices.append(index) + itemNode.removeFromSupernode() + } else { + var itemFrame = itemNode.frame + let itemThreshold: CGFloat = 200.0 + itemFrame.origin.y -= itemThreshold + itemFrame.size.height += itemThreshold * 2.0 + itemNode.updateIsVisible(visibleBounds.intersects(itemFrame)) + } + } + for index in removeItemIndices { + self.visibleItemsWithNodes.removeValue(forKey: index) + } + } + + override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { + /*self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + if let statusNode = self.statusNode, statusNode.alpha != 0.0 { + statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2) + }*/ + } + + override public func animateAdded(_ currentTimestamp: Double, duration: Double) { + /*self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + if let statusNode = self.statusNode, statusNode.alpha != 0.0 { + statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2) + }*/ + } + + override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { + /*self.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + if let statusNode = self.statusNode, statusNode.alpha != 0.0 { + statusNode.layer.animateAlpha(from: statusNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) + }*/ + } + + override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if case .tap = gesture { + } else { + if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { + return ChatMessageBubbleContentTapAction(content: .none) + } + } + + /*func makeActivate(_ urlRange: NSRange?) -> (() -> Promise?)? { + return { [weak self] in + guard let self else { + return nil + } + + let promise = Promise() + + self.linkProgressDisposable?.dispose() + + if self.linkProgressRange != nil { + self.linkProgressRange = nil + self.updateLinkProgressState() + } + + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + let updatedRange: NSRange? = value ? urlRange : nil + if self.linkProgressRange != updatedRange { + self.linkProgressRange = updatedRange + self.updateLinkProgressState() + } + }) + + return promise + } + }*/ + + return ChatMessageBubbleContentTapAction(content: .none) + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + override public func updateTouchesAtPoint(_ point: CGPoint?) { + } + + override public func updateSearchTextHighlightState(text: String?, messages: [MessageIndex]?) { + } + + override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + } + + override public func updateIsExtractedToContextPreview(_ value: Bool) { + } + + override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { + /*if let statusNode = self.statusNode, !statusNode.isHidden { + return statusNode.reactionView(value: value) + }*/ + return nil + } + + override public func messageEffectTargetView() -> UIView? { + /*if let statusNode = self.statusNode, !statusNode.isHidden { + return statusNode.messageEffectTargetView() + }*/ + return nil + } + + override public func getStatusNode() -> ASDisplayNode? { + return nil + //return self.statusNode + } +} diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift index 785818e083..3c1fbb09f3 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift @@ -822,6 +822,7 @@ private final class TextStyleEditSheetComponent: Component { theme: theme, strings: environmentValue.strings, actionTitle: actionButtonTitle, + displayProgress: self.isActionInProgress, action: isMainActionEnabled ? performMainAction : nil ) ), @@ -998,17 +999,20 @@ private final class ActionButtonsComponent: Component { let theme: PresentationTheme let strings: PresentationStrings let actionTitle: String + let displayProgress: Bool let action: (() -> Void)? init( theme: PresentationTheme, strings: PresentationStrings, actionTitle: String, + displayProgress: Bool, action: (() -> Void)? ) { self.theme = theme self.strings = strings self.actionTitle = actionTitle + self.displayProgress = displayProgress self.action = action } @@ -1022,6 +1026,9 @@ private final class ActionButtonsComponent: Component { if lhs.actionTitle != rhs.actionTitle { return false } + if lhs.displayProgress != rhs.displayProgress { + return false + } if (lhs.action == nil) != (rhs.action == nil) { return false } @@ -1070,7 +1077,7 @@ private final class ActionButtonsComponent: Component { )) ), isEnabled: component.action != nil, - displaysProgress: false, + displaysProgress: component.displayProgress, action: { [weak self] in guard let self, let component = self.component else { return diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index 90c5e4f0d2..1274aaf1fc 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -72,6 +72,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var enablePWA: Bool public var forceClearGlass: Bool public var debugRipple: Bool + public var debugRichText: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -121,7 +122,8 @@ public struct ExperimentalUISettings: Codable, Equatable { enableUpdates: false, enablePWA: false, forceClearGlass: false, - debugRipple: false + debugRipple: false, + debugRichText: false ) } @@ -172,7 +174,8 @@ public struct ExperimentalUISettings: Codable, Equatable { enableUpdates: Bool, enablePWA: Bool, forceClearGlass: Bool, - debugRipple: Bool + debugRipple: Bool, + debugRichText: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -221,6 +224,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.enablePWA = enablePWA self.forceClearGlass = forceClearGlass self.debugRipple = debugRipple + self.debugRichText = debugRichText } public init(from decoder: Decoder) throws { @@ -273,6 +277,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.enablePWA = try container.decodeIfPresent(Bool.self, forKey: "enablePWA") ?? false self.forceClearGlass = try container.decodeIfPresent(Bool.self, forKey: "forceClearGlass") ?? false self.debugRipple = try container.decodeIfPresent(Bool.self, forKey: "debugRipple") ?? false + self.debugRichText = try container.decodeIfPresent(Bool.self, forKey: "debugRichText") ?? false } public func encode(to encoder: Encoder) throws { @@ -325,6 +330,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encodeIfPresent(self.enablePWA, forKey: "enablePWA") try container.encodeIfPresent(self.forceClearGlass, forKey: "forceClearGlass") try container.encodeIfPresent(self.debugRipple, forKey: "debugRipple") + try container.encodeIfPresent(self.debugRichText, forKey: "debugRichText") } } From a386e79f59d35b9d37c619886aa82353705da65f Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 28 Apr 2026 19:19:33 +0400 Subject: [PATCH 09/69] Fix emoji status selection search --- .../EmojiStatusSelectionComponent.swift | 188 ++++++++++++------ 1 file changed, 125 insertions(+), 63 deletions(-) diff --git a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift index 01a8977756..9d68d758fe 100644 --- a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift @@ -260,6 +260,7 @@ public final class EmojiStatusSelectionController: ViewController { var id: AnyHashable var version: Int var isPreset: Bool + var canLoadMore: Bool } private struct EmojiSearchState { @@ -297,6 +298,7 @@ public final class EmojiStatusSelectionController: ViewController { private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? private let emojiSearchDisposable = MetaDisposable() + private var emojiSearchContext: EmojiSearchContext? private let emojiSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) private var emojiSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { didSet { @@ -412,7 +414,7 @@ public final class EmojiStatusSelectionController: ViewController { } else { strongSelf.stableEmptyResultEmoji = nil } - emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : .active) + emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : .active, canLoadMore: emojiSearchResult.canLoadMore) } else { strongSelf.stableEmptyResultEmoji = nil } @@ -478,20 +480,23 @@ public final class EmojiStatusSelectionController: ViewController { guard let self = self else { return } - + switch query { case .none: + self.emojiSearchContext = nil 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.emojiSearchContext = nil self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) } else { let context = self.context - + self.emojiSearchContext = nil + var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false) if !languageCode.lowercased().hasPrefix("en") { signal = signal @@ -505,7 +510,7 @@ public final class EmojiStatusSelectionController: ViewController { ) } } - + 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 { @@ -514,62 +519,60 @@ public final class EmojiStatusSelectionController: ViewController { 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 - } + + let resultSignal = combineLatest( + signal, + hasPremium + ) + |> mapToSignal { keywords, hasPremium -> Signal<(groups: [EmojiPagerContentComponent.ItemGroup], canLoadMore: Bool, isSearching: Bool, searchContext: EmojiSearchContext?), NoError> in + 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 { + } + + let currentEmojiSearchContext = context.engine.stickers.emojiSearchContext(query: query, emoticon: Array(allEmoticons.keys), inputLanguageCode: languageCode) + let emojiSearchContext: EmojiSearchContext? = currentEmojiSearchContext + let remoteSignal: Signal = currentEmojiSearchContext.state + let remotePacksSignal: Signal = context.engine.stickers.searchEmojiSets(query: query) + |> mapToSignal { localResult in + return .single(localResult) + |> then( + context.engine.stickers.searchEmojiSetsRemotely(query: query) + |> map { remoteResult in + return localResult.merge(with: remoteResult) + } + ) + } + + return combineLatest(remoteSignal, remotePacksSignal) + |> map { foundEmoji, foundPacks -> (groups: [EmojiPagerContentComponent.ItemGroup], canLoadMore: Bool, isSearching: Bool, searchContext: EmojiSearchContext?) in + var items: [EmojiPagerContentComponent.Item] = [] + + var existingIds = Set() + for itemFile in foundEmoji.items { + if existingIds.contains(itemFile.fileId) { continue } - if let alt = item.file.customEmojiAlt { - if !item.file.isPremiumEmoji || hasPremium { - if !alt.isEmpty, let keyword = allEmoticons[alt] { - result.append((alt, item.file, keyword)) - } else if alt == query { - result.append((alt, item.file, alt)) - } - } + existingIds.insert(itemFile.fileId) + if itemFile.isPremiumEmoji && !hasPremium { + continue } + 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) } - - var items: [EmojiPagerContentComponent.Item] = [] - - var existingIds = Set() - 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( + + var resultGroups: [EmojiPagerContentComponent.ItemGroup] = [] + resultGroups.append(EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, @@ -586,10 +589,64 @@ public final class EmojiStatusSelectionController: ViewController { headerItem: nil, fillWithLoadingPlaceholders: false, items: items - )] + )) + + for (collectionId, info, _, _) in foundPacks.infos { + if let info = info as? StickerPackCollectionInfo { + var topItems: [StickerPackItem] = [] + for e in foundPacks.entries { + if let item = e.item as? StickerPackItem { + if e.index.collectionId == collectionId { + topItems.append(item) + } + } + } + + var groupItems: [EmojiPagerContentComponent.Item] = [] + for item in 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) + } + + resultGroups.append(EmojiPagerContentComponent.ItemGroup( + supergroupId: AnyHashable(info.id), + groupId: AnyHashable(info.id), + title: info.title, + subtitle: nil, + badge: nil, + actionButtonTitle: nil, + isFeatured: false, + isPremiumLocked: false, + isEmbedded: false, + hasClear: false, + hasEdit: false, + collapsedLineCount: 3, + displayPremiumBadges: false, + headerItem: nil, + fillWithLoadingPlaceholders: false, + items: groupItems + )) + } + } + + return (resultGroups, foundEmoji.canLoadMore, foundEmoji.items.isEmpty && foundEmoji.isLoadingMore, emojiSearchContext) } } - + var version = 0 self.emojiSearchStateValue.isSearching = true self.emojiSearchDisposable.set((resultSignal @@ -598,12 +655,14 @@ public final class EmojiStatusSelectionController: ViewController { guard let self else { return } - - self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false) + + self.emojiSearchContext = result.searchContext + self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.groups, id: AnyHashable(query), version: version, isPreset: false, canLoadMore: result.canLoadMore), isSearching: result.isSearching) version += 1 })) } case let .category(value): + self.emojiSearchContext = nil let resultSignal = self.context.engine.stickers.searchEmoji(category: value) |> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in var items: [EmojiPagerContentComponent.Item] = [] @@ -677,11 +736,11 @@ public final class EmojiStatusSelectionController: ViewController { fillWithLoadingPlaceholders: true, items: [] ) - ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + ], id: AnyHashable(value.id), version: version, isPreset: true, canLoadMore: false), isSearching: false) return } - - self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) + + self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true, canLoadMore: false), isSearching: false) version += 1 })) } @@ -689,6 +748,9 @@ public final class EmojiStatusSelectionController: ViewController { updateScrollingToItemGroup: { }, onScroll: {}, + loadMore: { + self?.emojiSearchContext?.loadMore() + }, chatPeerId: nil, peekBehavior: nil, customLayout: nil, From bde3418ce5005c4521a87a315ede08a99243f02c Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 28 Apr 2026 19:16:08 +0200 Subject: [PATCH 10/69] Various fixes --- .../BrowserUI/Sources/BrowserMarkdown.swift | 2 +- .../Sources/ComposeTodoScreen.swift | 2 +- .../Sources/GiftItemComponent.swift | 38 ++++++++++++++---- .../Sources/ChatbotSetupScreen.swift | 6 +-- .../Resources/Animations/BotEmoji.tgs | Bin 30782 -> 0 bytes .../Resources/Animations/ChatAutomation.tgs | Bin 0 -> 15824 bytes .../ChatInterfaceStateNavigationButtons.swift | 4 +- .../Sources/ChatTextInputAttributes.swift | 27 ++++--------- 8 files changed, 45 insertions(+), 34 deletions(-) delete mode 100644 submodules/TelegramUI/Resources/Animations/BotEmoji.tgs create mode 100644 submodules/TelegramUI/Resources/Animations/ChatAutomation.tgs diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 17dbb5dc35..6ef7a0eea5 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -42,7 +42,7 @@ private let markdownVoidHTMLTags: Set = [ ] private struct MarkdownSafetyLimits { - let maxFileSize = 2_097_152 + let maxFileSize = 524_288 let maxLineLength = 32_768 let maxBlockquoteDepth = 64 let maxListIndent = 96 diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift index 3002238bac..5eb51a3f78 100644 --- a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift @@ -204,7 +204,7 @@ final class ComposeTodoScreenComponent: Component { for (id, itemView) in self.todoItemsSectionContainer.itemViews { if let view = itemView.contents.view as? ListComposePollOptionComponent.View, !view.isRevealed && !view.currentText.isEmpty { let viewFrame = view.convert(view.bounds, to: self.todoItemsSectionContainer) - let iconFrame = CGRect(origin: CGPoint(x: viewFrame.maxX - 40.0, y: viewFrame.minY), size: CGSize(width: viewFrame.height, height: viewFrame.height)) + let iconFrame = CGRect(origin: CGPoint(x: viewFrame.minX, y: viewFrame.minY), size: CGSize(width: 50.0, height: viewFrame.height)) if iconFrame.contains(localPoint) { return (id, itemView.contents) } diff --git a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift index 840df9d0fb..73e329bf11 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftItemComponent/Sources/GiftItemComponent.swift @@ -345,6 +345,7 @@ public final class GiftItemComponent: Component { private var selectionLayer: SimpleShapeLayer? private var checkLayer: CheckLayer? private var outlineLayer: SimpleLayer? + private var outlineRenderState: OutlineRenderState? private var animationFile: TelegramMediaFile? @@ -361,6 +362,14 @@ public final class GiftItemComponent: Component { private var giftAuctionTimer: SwiftSignalKit.Timer? + private struct OutlineRenderState: Equatable { + let size: CGSize + let cornerRadius: CGFloat + let outline: GiftItemComponent.Outline + let hasRibbon: Bool + let themeId: ObjectIdentifier + } + public var pattern: UIView? { if let view = self.patternView.view { return view @@ -1537,6 +1546,14 @@ public final class GiftItemComponent: Component { if let outline = component.outline { let lineWidth: CGFloat = 2.0 let outlineFrame = backgroundFrame + let hasRibbon = self.ribbon.layer.superlayer != nil + let outlineRenderState = OutlineRenderState( + size: outlineFrame.size, + cornerRadius: cornerRadius, + outline: outline, + hasRibbon: hasRibbon, + themeId: ObjectIdentifier(component.theme) + ) let outlineLayer: SimpleLayer if let current = self.outlineLayer { @@ -1544,12 +1561,15 @@ public final class GiftItemComponent: Component { } else { outlineLayer = SimpleLayer() self.outlineLayer = outlineLayer - if self.ribbon.layer.superlayer != nil { - self.layer.insertSublayer(outlineLayer, below: self.ribbon.layer) - } else { - self.layer.addSublayer(outlineLayer) - } - + } + + if hasRibbon { + self.layer.insertSublayer(outlineLayer, below: self.ribbon.layer) + } else if outlineLayer.superlayer == nil { + self.layer.addSublayer(outlineLayer) + } + + if self.outlineRenderState != outlineRenderState { let image = generateImage(outlineFrame.size, rotatedContext: { size, context in context.clear(CGRect(origin: .zero, size: size)) @@ -1588,11 +1608,13 @@ public final class GiftItemComponent: Component { } }) outlineLayer.contents = image?.cgImage - - outlineLayer.frame = outlineFrame + self.outlineRenderState = outlineRenderState } + + transition.setFrame(layer: outlineLayer, frame: outlineFrame) } else if let outlineLayer = self.outlineLayer { self.outlineLayer = nil + self.outlineRenderState = nil outlineLayer.removeFromSuperlayer() } diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index 1da0fd8d81..8020a1a2f4 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -698,13 +698,13 @@ final class ChatbotSetupScreenComponent: Component { let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(LottieComponent( - content: LottieComponent.AppBundleContent(name: "BotEmoji"), + content: LottieComponent.AppBundleContent(name: "ChatAutomation"), loop: false )), environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) + containerSize: CGSize(width: 140.0, height: 140.0) ) - let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight - 30.0), size: iconSize) + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: contentHeight - 50.0), size: iconSize) if let iconView = self.icon.view as? LottieComponent.View { if iconView.superview == nil { self.scrollView.addSubview(iconView) diff --git a/submodules/TelegramUI/Resources/Animations/BotEmoji.tgs b/submodules/TelegramUI/Resources/Animations/BotEmoji.tgs deleted file mode 100644 index 0076344f4c799f30199f553d5402e5f8b8c1daf8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30782 zcma&N1FUE<`{uiC+qP}nwr%fywr$(CZQD58wrzXPH~)*7+{w&sn)H|UZL^xJtUl@U zF8nA6fd3RA;H#d^+AWDj(l=^I-Xy?2{fC~@ZWb)qY!7J?FfLlvk`t3s5J^?uW$^b~ z+M-r_t#lB{=TR%cZb_hH^0x1 zn^Tiex4ho&&y1gUncp=bzpq<2e(}ptx4ss?->)FN#;5);c>K4&ue-fIUvkgi50`g0 zudlx!Z>WC1t1j=0dH8R-yT8vjr*n9}Z*_6I<)QSqeZF6hpIv$XA~)50&-u^Se?4D8 ze?90ybymqyzWh|S2}^joi!!hDpCXhiY)ih-b>IKmkGi!V!7-K&fOm0vee-51b=&`0 z&~Hkk-9Xy%(PLiFB?}h(@U2pHi&dbx4w=WL(mT_ss_v=pF=81zc%;q7a$n%1uA8dC z?wP3Zxkqq47t2g}jHQA1ng6q`?)3`VwrH)UyonP=zPqH)`BI@X+1MB8Hs$u*p}u`% ztSoigsbS-%ZzC*X%j~iHY73LXTG2yag1y;B8ttv)QfQlFVcdRCx;eP~TH5;B&c@jN z_<(1Rf6Ut}-*1o!QTK{l>@Z{f!6kKXaG#l;YIOE@b$AA}%Ux2!hfx))^-@33yotLl zChWo(&w$D5H8gui$tIw}Q_igc&8rq$ly)j8y_4dQHXI5@z z_D9GT-4ZPVugIF3GYlb+|X zZ|D2#`;wpU=TA&xeXe0$M}YcxM@i~}w#VJ*;%P`w)mx_vL~tPN$9`BcuQ~;;;3}&;kgLy|!H85^` zZ?VsA8OLZBEQ`O*FPlyjnyCRhd|kQ>mpL(BQkJ0=yj@3(XauF~7xI z{I5pkOzVpSQ@m)EmxL!f@Yhry0V}X*K!1W~xLrkbTtHa641^LT4c?N<(U?%g8UtZ_ zBV>@1<_#84$aU*dv~VmNUho=hxE^I&c12R2xWp<MU_BA6 z1WALw6cSJA9Ygq{$zp7&bfYT!*mF( zU?`nl2EGsSCRPwgp>u>94exxF8ix)B&n|E;mQ!Y2_{EqPJ2ObIZ1mb1(d}3to;K<= zXSFozZCE1s9Fpn&TpG2eoz(P!1wr047tp0FhDz|ZE{2t=6TY(je!fcF* zCa^9_&ZvYY@~?dyx_#~H^MBtX>pfjhXZ8B{jp4J0T`_q0l#i~MC>OK{cnKCTLe6I% zjf=cLAGJJC{(32@l^?lPX09yU{?h+G;``mP{xtgi{yabW`SPs#=6ma>>K_xY(~~Un z*zU905yS*4)^=W_XJgO(Iv!t2%G4`t^z=Vr2|C1iPbnu!QdXw zV4kwRy#L^*-^l+OP=$UAqCk{sSR(@39zw(WMo-Z|F+da)4OK(M;9e9Us{3P)9u?o8 z#xr1TG7A_DIjYoPGN!$-ZNW!&gSxi7z3?D1lA7xO2E9VDTqfefp@;;(PaojEV#!l) z7giMQdeQ_|Bw12n8jK5n;gY_XI=W?q*`N$0O%piHAN@Sv-|rZpR82r>P#V;Fm42oF z1MIRGbik58DOeDH8-xGk4Tk)_Jt%jBpyyg=OUEurHqu)zYK8_F_yXJef1wk9lTh(DLl2X-!b7?-6Qy=2gJMj zFTMHHzeq5bQj>-=30jp%Ho3-rT|J$-d#mB$mYJW%NjXof{aK)s=Vc7`ELWUUU~<_+ zwJ2uDJ2QfFg{R?YZ(DiMCj48LxfCvOomIjt!arl()yHz3H7P#d*2_~As4{wWdiD;xUHCIHwQzL2sx2)Hnd_-V6bqz!IcDxBY|7QZMdp9 zu_CrXb>R}DB+;?5M3qUdMdCExGOCCz9ldt02~%50vNorP^wJQB=|LZ^0PH3k1rK)b zso7cd=L+TRZUiVn%4lo8d-RPM7vwhD1yw&Sk^d$}Nzl*t2x?R>Naf$&@&nI3ZV;fo zsSS1GY5HL*LVU{r#SPAiGeNQ@moOt>eXpovZ^)@h$Hw~EE#ri+>!-k*CrX6#>FhcK zskb)RE?j6)`Bzbo&d>K9Uz&M%2Irrb`eBX3)(Z3mfHbsv_2ay8ttYSH z&FM&UXYVN_9Tuc>#E{!32SSXUf4T`0PCpMHo;Y~_%GZ^I8=JNl@Wd`vc1X=9lW(fU zaszOnX?iURbzl#AXT}uA_yye+!@CoPj)0B_PmKz8J6(jCZ!lWw_+TPM_)R&;lMZER z;e(QsB&1t}($F75u)MpI;ypFOc3?x=P~tp5hSdxU`()qwf|J!?>K9G?F*wk2WmGw= zhThkr-})fxc3_6g0{r#R%AWKLaX=YVgb*jh`Jcc;8$qrVRLKL5^W>V;%3BtAy_%J= z0KE|qKpyI>NQM=lCEUQ9+PCO!X8BFFJHq>gDn1JzV%Yrx1k_=usa52(Aws$nDCs<} z*m3w~ls-AI@Gay3JyM^-x8Q#PBk=zEHjhNnZ%4&SM#~Dv_OuVP)J*+#=FL62>H8ds zcDSddVhzz%6x?g2CfF@uD(l&Flz&*8N{`th!^+IZ4u>ADICy#dT0e`^bV5BL4s3AO z2>I3A?c||4vTDKIostYf9-rYsS_Ld0aaPDb@(rQ_Y3rbE0uo3{zAzbs2hxIIZ^DAL zbL-hr92EOS0n=9D+Bq_Zh-FC0{iB$h)eG~m_XZK6aC=G#>EH-s+*2tJ3d5s_=q52d zipOVgayXsdPLK|*A={k-{o*B>n(r89{W#h3btO7<6Z+5@!ZoY&_1*nCx^mUT^`-cD z$}?}0TNP!WT|ZuQ)L2bV)me{!1Wi?u1LaGaa77wbYXP3fFI`HE-&`JaUu0DOJPRf@ zpa03H38}a}b+)xuh})*{qu*6Kn7Ax9%bX;(ANVLD2wQDsoWbeN1;CQt*^ z0<}cNESKRJxkx4V`Qo^7?XXO3wl%m3$Jo1$JqvCg2-awZQ9u=$Pcc{}j+i(mC2W+f zVd_=7F$PNFA&d=Od`4Tfa`Fe0t8T&SK?LXd%! z&AKv_2Surqv;f>mAc85G?%0&^+(|wezmyzBOTl8_Rsfm>p93FH7&^BA^eIgMdVJsH zTVTWLpbp7|kh<~Tn(!es3Ry@#`9%fjRU5=q4jgYA-()zp*u5oTB9%G-PG=EO>t853 z?pb?-vUYB-Kd!7$duF$IS2L?V?EipM%Y`d!o=}Xa&C!+GrqwQS@M)$zIpq&*z5I6 z@w_Y3H0lQ z%#>(=RHF`$l<z= z;pC6}-TxE4HbOKN>8Oy{?C6&c1t@&%9A%xJZYns3z|~>c`B#XHnRFl()T*r)dihC< zZ6{D_v;uA2+fq3qV?cQ~nGz|u*cF@nGI1Do#-`nUsWixpAglRCDF*%q^H-)dtXvzn z;Gy?Yi;jlQKut%jxz?uilQGjH@Fc-zZ~8Wh{w72_hNp+kW`$XCN=G>s)TJfN9R=EZ z!_kv*Q~HzSYM2`vYs*qtm#PdlW$-$*whlJyS;p8hQ&mJNh-VC_C>X3UPp7%L{+l=9 zI5~BOhc_b_6@B$OTdEDK)6sFQ)+!X{uKs-m3+w{eX{inO_viw{LPJ!s9mvRc{ceHh)v=!!kyEdHt6Fn}1j z05MRkS8y!Os3h1z^PRvl(Z*4Y7-)NF3-6a$#eBv845M18aCp@g|{Z0iW%^vaDb;P^#h-UW&Hy z8FLKN(n0`XDCCbhQv?9QOdVJOQA(5^X+ZVAhm*Jv13TiVK%5PU-}n~)UrOJ%IC}n( zPcg)28;6Yn!gvnZ&KQQmGB+N3>EBuPGEQ}t)5)sYj)Ti&H;~HGiKb~{leOn*;`-F` zcYE5)$-gzL-PH8Vw~@CUBwBWm>HDckF(S3Bqqz}rL@Qr{yWeLCAcC2 z>n}^w#$yt=%BaRwMe2-FxW;wG%Zzd!WPQueHmlNuIj!#w*ii{38@1}%$V2i^Tar8w21C$S{!SyX#&oJ3FeF{b*2&@zp6 zEZCbkLN(*PM0}sd(l*DN&%7wwW&oK9~*=q~r3UX!|bZYOyhwg!3etdSc`p`o8H~%-Bb* zBG?O%eKm7*PaF+^@G=(6y<);$$VnTGQ3Y7$x)7_KS1aiIjail>FmJQS=DqikClr}Q z7O{n#{{wW}v5W<31ex@LXW1TNzbk=R^&%GW&rdeMeG}gjB4!G%7IF(k+M~@Bn3;!? zlL#5yC%UiWxu2lU;R_eJ8Jy9QNLXlV?5lV#lzlM8aLd;DaNMbtfiewGem3QqZlwyq_BiJ^L z%?!0)32tz4Zpm=ZLAdzB#~%dup&j1*^dKX+NKT5A{~0E8*W!2^FcX3gF~#BBb&R}e z5hdji01~YrXu~{yrWydRfxHpkBfok8fToQ=zX!Fax1XE2K9nBz1(#jaYhtXZgx*{1 z$u*m+DY^S9DPQ5KsxlFBD08j}G#tD`T-!u8m!#J3&X2mTGAb`Fg2*fvF9kHAly_%O zH&*iTx)gu(dSlqepa1_$tAhWJw33!7LZ@`9qG2?RpfsuquOVuUT%-CQ;Tk?<%}(4^ zuVEOC&jKQBhR=$R1DWN785m0S9%KYrk2hC9D?o&+Is3xAx>zlLy>@uTruVns5Z-bM zLNJhjqYapfM+}{!@s240bRiJw+CBgVbUr3Q1`-#c{d;15^fWFl0Rg5XUx=sJld_V$ zA_m>7@QZ=BpgwRA>a!mTl%8~5=c}e3hz6@z!aAa~7A6vhV+M+slpFHAkW)K-Hr9*0 z3>b<0u()GW7VZ4r!n9p!m!`a5z^l-VTQWBp+O{24EyX08y1Aco)+&KUKf;)_iBUGF zrhZU61eGR2su+*9wAmy%;sq9GbbK_S9Gx@C0Q`J>4eXu|pqMI>=F4GxQ1-|CRS0bf z_U7LBMnse!&Qk@xPiEg|)+!Cd&AxoFsI0#w8xr_%8&QP0RWtAX)f+%9aMo z^1@c8t+=65fE&QAC6~SYIhyEt{~(hd8`~!Hy~IUqJ3+ad+vPu_vnBT5UhgyME@j|* zyjDe;9S?)&pLe}_9NI-PZQdV)rg`W1+5|gi&|;}?-t4{SL*Rgk7z$266d`KwZM!>u z-+p|4pO26HyT*mxzaRJVXB#SUG=u@!YRKaW!L@50G5jRxrnKFCk9b>8ngHehX1r4@ zTm{sxF2Db-B;prN33=ShM%MgB9ahC}SAJ1Q%QiXwU3I7hS_g%09svBTI#tOa5vSE? zt1$eYs`5ug%7^nSA7PClL{oF;HOC=6-?7)zFN7rr&BjqcJvMu?tc})R%y?3oI*n)5 zyHHsnmJFdYY;qp*mVi;mgun5|S0>IO)@8%1lofxDqS>HumAjNzQEi`!qBt&YnW8nB z9;YS5GZKv;^&x2`3C*1@kqS!UzSu8#+(TbmT)IppbbR7^62)TFhHuu^y>NBI#JJzj zydeW^G|RsF&GnJkz<1S-84ODQkY$a@a+#|$sp zby6X_16(?1*}yfIn7?Ots~T?771^-2oB|*pt}k0_gnDXWD5j}an@_@J&E#9X?oasi5o9QNmLI%`c22(3kNnmo}%PhdO?hPnw#UWjMAEl_P1edOr$l+yaUKUEvae43HqVDx|mjZFBDp0iD8hKEQ(z82MVX*@dhQ z0VxnNtBs2Hc@HMm*S6(V-Me1DklJ=t4d++arY+p|(!y{GM5N*lIGzbjy!Q^xMs_pE zkbRMi&Mm1RCMpB0Ldo1?Pi^;DJDycu~UT>l^r`q3FyFrdyvS4mnjeE1dI31oWlNTCj8Ji)$xw|*%^dt_g1wQvbMGk#%HgGkCW{{|3;vkD&1XL4=#CHsG4?AEdz^QH&P{;- zwQmu*(aazm2%#FG07KHTa3x_Ws0av8U9_d9_@~n6L8Z}FAZbtRh}#8}HY4KNrnSkG zXAT^M@mqmeP8IdX5W88|qSYCp*gC;)<1q1k_t5JJ$g}j}Qp@xUlLS#5;bf=Iyu=SN z`_hy3YbfAZots;X6E4b1oHDMCKEuZ`SGBoepcf?wq;owju84alEz}OdZWX|c05un> zj~pF;g41g>E^7cm3OvRDO;2Z=W@}`vVvQ*(&*2oXCquk^?qUDl;Y^vjL!9xgN!Trd8E~qg5 zei|z(1*l-E}lv+aZ=wB3=z_ zMG$!+H+R?2*O&gpLdUz<8t1{{iJ| zsC|v7VyLW$GY}pxLje%Gi@nmu=e4nnb#7CnE&k|Oqv5xfh>>6+gsP&2AQ60l4pbN} z6BzV6((4AD>fQ1K3}rQr>fr1+K`^`y#s;BM(ON4Fz}~vRN=u2r1jh!p8|8z-OIQoT z7R<7OUk@;qomys=t0XmE0r7DUfI)k4vPODq&0Fv0b-_hmdG1 zPyM%=<1q?g!4CF$i1QSmF;qS*!aC6Q537`n{IC-+Dc#_btRI{4DM#PF#^9rLv zVGB~tAA@9zn!UEbT_|7bxBnMJtVy+~0e+++R{VZ9yQl?j`u@qvTzd}5LH6)yGHzrr zl8@-oL_c#wyn9@eKF50GOgx2q%{qqIBKtajyKq*$+K^%Mw`URs>ArwrZQ7DC?!5TR zSA0I7f6lc$d*DM|U>B|{ROd~a;Og(!*YzRIMyIZ*_AbA_?*^y8ukQiChxrM@jQa*U z-B@1U_G9z?>8)Ik1Qgz_H|hJko`P*=6$g0n0KvKO-+N4XyZ+JUY7?FAOAC+rolH{g zC;$ip%muQ8{6M+$;_=u=gecy=orBkl0MnSyywb0B-PU#eG&!M{bE@nyDbhxhQKuZZ&g z+w#$K6c+xa!09^o-%!g4HDcaVkFsGb=7NFHsNBW{XS^`PR@@$g$v^F`#gx$Kg=)vd zIU~^qkIj#v9JgH%{P*bwa*0A=G@H*c9(f}J_!jo_&ACn^Sek_!3U++sSey=!L~G9|l7lUIHcboG*| zWpWMIxI6kp-s^k%WURVj*0U@4>0Q@oE0puR1WU=94wtlBXX&WX{85)-bUbfpY1T&U z8|q8J9SUc;rMGkvwIwzDXgCg52=U z2Xm+8rqLULet5@M>ELEj*XgsnwhdTiuowb~iX>u8K6up=YpJv8R( z8$2Z)ytqCK5`c5@D&pcmWglCZDzBysrLvn|V^YDij)DzB;zrCcRnDL@L8Di5f9y8C*6 znAC*_LKB|D36N`Ph500diaF8QgGG8wcpSycod(X=rIm|qYk4mXRqJ$_V zQiV$CJ%}h}7PEU5)%)uiz@#w8zTdAlfS|{t2GJWeKxML6i7bVwB3ZIThRRflGD#{! zg+wVqLX_xVr$8}K0+a|9LWRK5CNR-0x)^J2s3LB80cwCE@mNZ1^Rk190TmsjpOYP5 zp2D=EWkvQ-d7(cTQh++L9SvN7s_gMENNjs1M{|pyXPorB8my3-J&1M))aCZohntz> zk87Yv?f@A+R{XOn1Md*^h$iDL=Zx|AW>qo@%|aqVFz0T)6t09>OPR5t5WPw6xUQO^ zs+dLfSd$_Dcd(HmhkSUt1j!1gw7*Ij!6k8E^$S<7#fUts3sN^?X>1A0+EGR{NZ&G42We=^PfebYofJy`G07slny_p!c! zALXPcfATXTf!eI4AF#|Wxd1HgX*!VFnKl!S97NE~p21Hq9#Mo#yl;CR1N;D0DC4yV zx~%FM?+P#Ugq8Od|!S zp~Q3osJvJLqpG1=h$gC;YWg3*z2q){><1^nZ0(8MHRiqK8>3+bU~E~^j%?bowc?m? z`UNrZq+dtQ0!%Lr!ISyB#)8Ri<(u;QY24?rQ$O$DYsn<>k(?y)lC%V#$+78oPI}U~ z%x!1ghjQ_uf}||yhZ>o9?2ls__1pxOG}+-6eo?mOj1KrtuaPnVU7dYSh z=#h!dGd}%4kYYWv;Dn|%Iezh`+DC)nIr#P7h}GV;bsK}A*Wdgx%^7>lxh>n@KeW9)r~uIx)QK6JtO8c#1FcIM9k+4$({IU?jvsT>mxe@sbDivm|}f~{BPQ! z(|Um$xj)Wq;#E7pO%^TyC(*?KQjJO`+$T%fge%*8Hr%-1y3U{RIcqKjV(%<+2T{|7 ztT%7A@1dNrLFS17CNdz3aRpujDvY17r>N| z9ojJ_@GB8`I?DmD@#T~B*d8f>pYN*X!d!OfvM~y z3=*S)Va>-Ok5pI6k5oJHeAcm<)gElPD>%<0kKLY^ynRPjDR*G3taw(nb4p2ZfzN8# z?`}RyDGbb%{`(a?bGa(IzG%7#7&d#hqY%G)g#Qx1s3fR?B-a7$AB^}Ifa$x;mw^I^ z$SjyudnwzR?~gT~`TyOe=d_sJRJ~-KG&#>!{p`OXffbaLtSH+3Z3S-i_u>PsAHN5M z!+vX$aI7^FOj^H}nZ&o8#L*T#Q5y{fFnD{|^28aT$JIG$L+8tq16L8^A6hZ1jNJ8s zXnFoa;VXyQmxanddibUIdD)jI33^p`yRrK8iJ3AW{#NSaBFx!nt~{DMg@; zb29o>-DzDyZxj@SOK~|sI+a9qy=K^EQHNOu{DvYe4Ub_+pU<9px~yLykp?od!Hn4T zO(uwU@km)W2oD^d;gn@WkQ^f6zb?Z7OCexwmr1| zDysmWK{w!x;cPgShjKJsz>e^jch)yfSz$GYWrGP4uZr&t5DOGn)P#ggT=iz!=gY0=oi`mH0#A5|>fnIq5 zsOTa%Q*w$}X=EOZ*`dJbx{Dns5T!h*RPiGEiN+0dr_LGiVT7biTzd=5HZ#BQi!FeczfxZa@2-6pHcWzl3ne_hLVE{-#TQ<3GO%;S3tzu~;{Ny$(}uV}mg z4V`l3lBgtd5-aejxS*ec9K;pG4a+pJwChOMW)FZ=)vetr^nIX;lm$Hxf?Z+(B(b2D z_RRC2v65ssS?1e2=%o*{WZtb2^7>J}zfEU7!Qzo&SyO~N+**o^8{l%W<#7x|=X&Bh zI?Rcxg3+A}kA0Y)NFL?ma=GJ0;zI|MP(XZf;|U#4?ToE67aPIiSvJKI)#C*kR<}&1 zDGD0a;m0Frs@6!vlWzZ20Z(?P9_%cQA}%faDIzsNu{kk!vN3EgIwE{E95wXX$1K5OTR3 z2wEhkRR}J+i{Z{;nAhDTA-g3d?y4}2M~StLT?Uq0J2e2lWA<(EGmW)8ns(fumx5ln zEfnhq#p1#Bt^&vb{96$K8-?Y01cRX4G5j*~w~HW<2*Ip1F>X{1p=EPPq^YSHwJ*QtRe9YIG%aIlQ>A4wv{{x zJhIC8wvKIjUQiBgrR!nrhgx7{{e*S%=iM=vP@U+{}1^yH@ zrZ^`lZdmjvp|YE-F<9DHCl-?uku^^&#@XfAJ(I3NE}VM&Q?hNdKhx;5LBj$EESjr^ zaa>VI9IGR*o;S;$8Zel=JHE$=S%jRy`xdIpeXbmX_KpN|6w=q|2fRDz z8tYNUCi#lC&DxWovlFJ&n9W^$(T@vX8(af#hFCW0XSazgm4b)6Zvxx653+x0Am45WoLz95`_tL}#9DWh) z6LGNK(Wanfdp!9{OZcwD(s16#+4NAG`by{5PCyLs+?Ryr>bl1j}s&{4*(0?{x@eMbnDfj89TnzD>aiEO*qML{T&F9NI_@x3dpKx#@Hkn%A;OMz?XM>6 z;47(^m6FgI0y3!p(3Qef)Uk>q;UL4nGm=H*?1(iyYo4D}bSMCFE8Rq6vP^UsABh1u z*JVh4m9ZDnxa*l*^|K#N0do;dG;BV+NI{FM{xB?moT}`g@%F~mHrqi^EaliH*jaoM zp;k_0viG5ilQ_ z3e%y3ynxdTVEj1t>6No##z-+Zr**Izt_<*s!_oDULu7y^p=?!7>Ui}=Lw_I~X2v8e z0PFLlJC2dAu6&po zDj|xZBM!=Nk>l486re) z@8dX<3H1?8L&l@;eMeQ=CS8QHYVjD+9zq$w<&#}bSaO4&KKe1Knv5X6H*hlR_ zZ8n)0bP@rucId%Ra5K<+kT2!y3bXTpc3KF5(-Z5$>OI*p=qmJyz%vv*!p~a&-YjnN zWoScjYpx5uD*COJ9wy)^=3CL>@f3+_O|hg^nm=xcUCf>RVcFLvEAC>y-NAndBMhPX zm?59gM$9hZ4feV@;-o^91^o!|Jv{;Yr13X222~tnp9DuR@X(FhIgj>hEOz8F>IC`9 zPU8L7P#ZR55_eoN`=Eu}S9s4y_esbM(&Bu@TOCze8yaI$Y^!aIO|hvrHP%Nb+0=ll z8e{wgdQHtWs}9HcKv&IVr*r4^pK4C)kbO8>rdv_i4m12MpqMKY(?7> z0*qP4*9hAH7NH-rQ;$O=Nn?uWkHQE}7GbMtW)%vbp-VaPh+39d;eY@XqdL4S%|Hfx zgj7l!m|{{at7Q($46yoW*WZliqIcJPOK}eI!;HIg!YfuxoFI3|grV;4-oO#-g1NF$ zV%0hH_T57cQUAeD%+S3FI`rze+6Q+qN7$jHC9NUcSkek*xk}h!LJKwCy!)D}A=qfp zbY83h56(lR(S78|r2!>coyM23(LKv5tvzCB*I&la{x#8vnWi9Q+A&N~c=M9MDAz!K zhSr%3cAfKD_*}@Uot!J}@irMx$U@<%?$Oj~fD(plvZq1kObBWLXQBjv%>h;Fa1-Z* zTEl%*AYb$9vFPW3MNy+?we?0IB^zyrQfKh|?3TdQs6;_hGDx|soU?JsdMp@nb7?q< z85+=lRr`F%3FDA2Z8fNL^O*5{s8MQ+Uts_H^@ih&5IShV+<52~wum{7UlxYHZ9vu@ z=B~9~%8pl#jn^TYf{hY7r7GEyG^nv}z6RBL}Ea>NGz!#`pi-QbZiY zUE$(}m?InHPtut#UEuM6O2$jDxd~cGLS?!0%i~xrS1i{n@C>;?tyKR9AM9|;heIOR z%_$z&%H7}-nI@f{yYrsVRW)>Lk0u^tOvbZANVClSI|qvw(R}cc$q|w3)Mj--CFwmz zFSAkWn?T@H%wt!?wvEv|YJe^H_)THf-Yvk5POHx8ye+#1dBD+pLs!xW^#v=nG^+#_ zwy?dFV=3lPt-E;I0f$@BA_>@?dp4a0iSJfTi|AMpI1?1!IOap z*dOqEB6ZBIWT#L2^Hb$A+ilBFQLGw! z=8(9I)Zu*O9plQ^JDGFdWgAI0>ykVjBG9$attw~1WD1q6PB(TZ-FiB%WJbxl**M?K z-3K~{^Kb|_4Db2;(KM_5dSm(401UA-R*y98rCc`tey#R~7KC?f*#DQrtK%WqzU{Tt zR{=$P>1Lw;#)(Z@O(#h!HXDfl#snOK_Ai&|1it0T( zl55fs*1E>u`x{B|tN+jWeKVJ~?PwuvtDt;824I9 zbmf9Q82_BS2Pd4Y)YWDb2SI%ALsXN0cMWVjuB>7(BP7qq$B4 z3tYtiR<-?w$q>{_d*IdogSut+>FYPU>OjBBoO8b#dVzj0p-XU$1rP(GgR|k{z$Avq%njsFO>+j@1MbIqt-wlwn zVOEgbIB%^nL(svQ3Tn35VKKozb=YilI&61O)3s`2D*{;|mbn*Y-rFN@0U)+REIB~X zK79O3|vt;=xQHCV7p;4XRZD7N_5bWLEB0)Cwfu{Lv3n z<88u_db_J`hxN5Z>LD}So`z=y4rEb6>)LRT$PmD2DB^lu@UtNUEgp=lk7aSLWTQQr z9JY6jcDsj<$$HH(#A-eUWJ2~;A%Lst!uWb$C(MMApnj3Cqq3vX;r;e1_ zoRbixl`QZ7dUhu%B#NBTj&Vv;7#7Mk!$#C*?O52;`sIS%2JNXd3c^SJEfOO{EnYTA zPa3*9*pFe&i1!wbD@#XZpCq|sSv zb=%!`78`ZumM!3A{?;w$!Wt(VkG>u48)L+bHv6ed&(Xu+o41L?cQd`o5IeS0c{$~P_X%-h1|`SNBK@SPIqjl z1=NHMywmsWAZke&tzNx`UFf_blHEGHob~qQ6=`R}x;|gE)~~lD=pi-G#rG%+)g6~j zGKURH&F6NMBcLBiZ^OLd(yB+KkKMylrft6Vd$zK8*-8Wfg5|dh3A?mGaCS23L9ouZ zbjJjtX_{+YLfKo>QgaDc@!@jqSMCAL?YoVtPTc|lW4D?aUbyg1ZJ(~zl@ncSYVM$N zU(H<|*V{z2qrUH-&ocU5kzC#iuf_MGj#lyj=VjQ#uUOQ$z1Xg>rdw{M zL)l2+^Kr$0ABNrO)@Nas7^W@gQR4_6Cb`*azdW9XYG*h6GW3lR-{t?*e7D+8j(_#t z6kWbgK38Lht*d`&tE5^)g>) zqD}C+EnC3V%&CYzEV#I`MYJ!R1;UajPizeEpCw;}CU6A;3;knZDW%J?Cxq{1WdMlJ zgwu@(*Ncd&?k?3|>W!D~??wE`rhiwuFyI{!d)JfdxYpCA{ZqM=C!0e9Q>oy6F;-u| zLT)@HJNQkT`Er$|eC8U&3j!y!hQu2z7RAa7FN5CyGHihg;A*U2*_L%rg$-uH3G@{= z2=&$b7$%Iv%v%G)Ow&BUKWN`{qB#0#!2`guo7DWoJT;C0S~Uu1^oN#3lQc&lJ}Lb@ zo+c0f*w)w&7e!6(?9xo-eA&EI*tNUa*p5%oeeN0in=D2rpt$~{VX)u(}cO5 zE|DZcLX!TTtD-9bXx**P!W+q&MuL9fkwPPvLz9PZKX&gOa;i5NbCRu`WS{MHq$SUv zkxPT#wVtg;#jBB(u(+z_F?f$o;>1PW3vVwFkh!pQlKJp~(A5A!XGXvgd+}qwA)-jjPW=9TcUkpiZ9e5sRd(m4a@h|jekLLhfIsmM`7~YJM zW|u?Z+gw}HwHUO68SVYW(ZH46*$5e)^nG8$l_}5M0Tpc!j2fp4*@Bx@8-7*!1*_B% zn6k;chodFj*=m4p+4`{aUB+LHz*e3OM;}0EBv+Gb9&((*&lhG_+y>LMHVJV&0ds3f zd#YIh_aO~OVDqx!IqwXKx(A_X>%Yt-bxPdJyiyB_y#;vbgVN}~P<)B!wJ2*Lt78vDXx-|#h5EgVqA8nAa`b~o>GJh3%Nd*IEj ziF5eQ#vLS*Rp&?3b>dtDRNO~@uj0oMG|AAG2izsgVCOc_`Ni(F%dVH|hsSA>puOV2 zk4R5%!^~0eXk&JjEm8v-&>y}18$=%C&s9aAlo}JQO^1mk1qp`&iy@9GQV3_C%*M&$ z2xuC0{dEA@`H+T6J9LItF=`-LfU_*^Iio>s^(r!696WKy9bV511ld-yk+q7aZSDq7 z0~fPx0f|@G@J542T%@uv&`dDGtSkcO>aB@lT+k4rP{E$+vf`>reBkOejB8oE=!gnH!*w65fu7OE?dbdH!%oFn>BliR9JCdZ;KvZ%7Xr zc&N}~2af3V_Oq(IU(?&UZc}A0*||Vf`W2HsGC8Ll|jJ1 z5%+)q+ibr81m(rWtg<0aH$+uOY{#FrS5UeM^M(7fl?)`8m=6CDk3_<7PIw7MgAEME zQ%19(8gmw=arAGr&8On_AZ7_ke}TIPRD%ER&s3qf2DUwExs=VE_eGEgCBvhz``T`% zgNDAOVKZSU4+rOzN+;6?to6h@+S1B8=aoyfvviCVvRsIE-92HEhp&hCyoqdl4S6IV zB$54WAhB(S(BBD}HK2`B$WFDV>9%dHCk3pMFwzQkCzoOl;9Uh|SyQbLKf{Y98>`FQ zX@;HiFIUyrI@!a@)uBP>pd1gKM{&?2Nfmzxv?tONhgMa6$ILD+5vye^90YLsYs(Bc z3iDCS6SPK~ z69jJq^DiXWx;O4}vp?sia(XiorKu1n@L2vOg?Z|8ONVc!K~6#T0k4CO2u_+|N9a+= zw-4UO1>8kFDY?1 z^WXY<=ip4buirbilZkB`S8N-TWMX5&E4FQGVrydCnAo;$>z(_4&-47w`_^+#RoAMn z&t6?!UEP1|Ui-VgyUj6b_eO7SYAYWIvdfs$-RdBYom4~VtZB}~l=Og@iuGsk-$~0O z69}V9=bAq}RZH4O$YnM@grwkB5?D-(`Nd5<%gSSvn)|y=v?lr-`tRI@R&hUh@uIPZ z*#lg(%O}Cj-zNoWc|=$g;pZjjA!nK+nfbGV%K1i#TT|F+cub& zeWH&S=2Xe94?WIvQTkP>>ZIn_QA17rqA#+=`5m7UoGqQ1&X2%h{2*+7sNu^crbLg8 zrnnY#co&T+-}cA%tNF?nc<`oRSP$U4M*T8j$``+`z^^AKWB}`80FlE5q3yC~|BdUx za%lZllq({K9HT=r0Y|y9T{-k@ZKZdi8Ca9BHAkidj7SzdjSo;y#S(UPkbhoAv`Gn4 zZZ^9PpD>n_;_BAsx2^=2`y0c=*>zp9Ukcf_F@>yf-vE?v-iguN7kTixdIbtc69k*+;R8`@WT0HW~q;!wXsl|+VPU3{CFR4+Lu zl#EBSW)Dk-Gr*|C_pMd6pWnv5r(4*n(7>0Y!{uY;>Iecp9Os@w4KSt7$?D@n4-cKA ziX~z!pujmt@XrsuLK%qG6-wYc2{dTj{&{>%>71l9KcsK4YxS?U9E!|B!`>fw(ZH>KJJ24W%%an03*v3nzBhT(4U(R zw>fgrF=?amQj)_!I5B7q^lq9y6jJRq{M1Zmm@&;}(3F{VU%9a^Yg9~`ri$z|t_yO4 z>@c0%lUBrMtv@BlmJwdQ=Ai?kz`Fw#2QBB7_!XGi5u#yj1}j@k9d*K$o6YC~kmm=f zp~t!uVkA_L9OfOX(&_d~nefkwmI*(?Y4FO1DYWk$8dufsn8L*c@+-#b|(`(Rs)sm?ubR;!?RYr#JAltQ@7_S*`ImH zO4-$oN+6Krnk>8$J>DTRdGN2qrJOQ{{B*ysO3*+SDZkZ#-o9y#T6V>u%I}WZ1Cq)kQ7PUKnLsEu7~*=Xn9BtHrN?N6P79E0 zhhV1xptw{*dk_|xAOv#J=(#{Jb_Kt`xOVQ}0o^GM1wW*e|4-ikmi3>D|0n3jg-^9T zNPgz=@cE+L>h@<)l`-4lCMmK+hwosQ221fDG*|l{NI?JJ_&@Ofjy94148FZ>ZR(;n zTwk^9jJ4+&!&9{q*+lUqsIld`&X562BM~Aj@{(Po`gi^yS`jWo7vT$$Y(bX^r%3fh z2RCVN3%wJeIfL)kYllEy7!VeYhjw;zj5V9R><$p&UDsA_1Vf82FjXHLQ&FFNmkXpp zo=9}L;M}t0E>C$J#-|zI%Tzw_z_4<7D zlw%tm_lm38?i1N4`ycf_QQ4hD4Ai7+0r=m-6d}`{d;PV$XvO>9TQw5@-6x$hF)UcQ zP*97K_Y+bu?pRf=l4p6QE^UR+)StdCVf~3oVUvj}Ws`~QJ?7r5cF1vg-lfzhYa1?~ zSX4LZKa6D8KHSv;+*@*;q8tadY31H8GPlA^-cKL(FnljJmanySTzl%}NOpKgj?7hD z6&$4detYA1dd?d&p|T(R4|?*uovoQ?G2M(%$=R2E@;TxR;($lHC#1$Mj{F;$c@tRf zzRcLxU&P1o-0^~cD>_{I|8GTy4U$jAzEWr31cX@9grtsr7nBMNC1m~P52Pp8X4b07 zU>!6AafmbeQScaLwjK`WS=!8e=Cwef{4Bc4D!int-|hqRh;O3Hr! zg&E@=kBF*&Dn|F1ms2{GJ+)x!0CuYFOZsKq}yTBzvbf_6gk!8){OGEH~MfC`FDrYA6g@& zP-@#|CX}@#D+G1{Z2+osyVOLFNFW_L+CRf6YxofB)n;t#%8J;eM^wMR5@=7)oC+aP ztrcano9ngxoER!HY|nt57IY&_-9%ABI+}<(Q^iA=6Kl%2F3+TGt~Wi8PjwIF!zCpn zs3YhaXYb&=EQ`CWV0hv;s6PFJY|>n5Eyc)-^w96<_};wb`*nY?HjI@vs44960OP|= z*n5iPl?6?6?02`fK>Zm#cT?WHYv;_TCqLoq#G}`!@GkjwL;H*{9+fqz5&5kBaQB_+ zQ^Pg8S}&NglM8jr@vpHN z-rU3pD)ZS9r!L<+vRmCBjo)`+&r;wa zO2Y2&4!G0bF5hKZ4{LWW6}iFq{m;#xSDh5J9nLkASMDqz$mWH4;U|29s!Uv(OW)g+ zX+aQ`f5yRGYyS<*v?_sWDCmOS)q*D{8KWVvFNxLk)p@g7-xr_2yLXd1QTft_Z1aH|Sz0p8@;un@%lFpHs?qs}PFZ`? z^Ho<^V^=l(+Ub5~W_sc6kaV^n^t7l_+^h7-XIfLdH=${La1+05i#t>WP=*-GTH1c) zg;BYjpV8FO?VRJRVy`fb_eG0qLGiG;oHelM)gMW^ZvZ-{!Zc(o)$T1@4vA#YaW|>_l7D?#B1m% zIfO6~wO8LyOKjSV4u z((gG|{v#bnQkh{3ma2ACj{2qkt>tFJDssI7aeL8rZ2 zU*G8P_-ctCqOE-k;`A|0ZfztXIm~fpV@Jk!Do-`Gd-(oYb&KVJ;D3c$u0!6(k62f$ zx+rcSob-c|;xnHy33!^!*S2W;kmd9Ju%g>avnOiiAG+cXukT7LR3&o(d4E6X7v(ni5&P#=s9rw~rok1GG~<$HE9$?WsfEqk2b zfnY*~oh^cfmP;uC4{?^&Z0K?*Khsi`>QT^a^hCLq3;o88Jgib`C^gkt zwIn6N>8WaC8CR7-?6VksJu`Sk5D=)ZBN^PBtB`ANLxEFEYrA)QnH#KOb37qzNOLu( zzn||hSWl~dJc24616Mqef}Eb}kmnQP+@r^8zmtz`wlZV54NDV7p#WB`gq^6M{*Y1w z+12hViA`suIwS`&9X}&%TSAf9DcbWqtjV`j7il6D1Ic!po1j+_H5N(yO0lyW?#B<-mKvY0W!;z1Hx8oNjjQrjOs zBFX2@25{VpRHmAoW#cmV?142c&~>U9N9$^q=atjHQR`fq)!T=EI+y1|&YUOIM5laK z+Uf~aY%UBkuopg){%o(VLgpxg(*&P6)YTN-v}wRbHN}BTNqbBe!1$@#It^ODM;04( zY{MG{z3IFmY_&D#5%juzZ5x=V0xvzfc!~H?>wnUPqWWPpK10a%C(TbFZLN&D1 z!f{a_0Bv?4MBAW z1RhEAUkKNH%UGhfA$9x-Vp9wF(fgv{_(k!}gcy-xKKzK;UTW*mK{>YD&mwfM^PX*I z9g3wxc8;_#m%R%tl(z5RiS8C(bG2*RMsGo5-WUgq`TeSUre*J&l^M_j&EkdCUcrc6 z2Vr-{&JEP<)E23r9jx3Gu~ecvLbiSw1^R*i`o|YAewncz;%>foh8(b(~3!dH)T0*#E35{r}!O%~>2&rQtL_{Qr+5U6tq`h%VRmhbcZ z&D53P2Cnl7$94NVW_P6R%YE>ez?@){kbuh345s1g#<3qVQP9occs1#Gbq$RCtBOL6~zRx+zhb{cwXxTXGRIu^RhE95v;r;fk-zA@2yLMq4PYvK@zQ;0|6gBrO9$F1!x&N6jlqvWE>?_98{P_ z#y2JK!k!eVRa!bIuBMUUq60%0>nP|WC}LuPjp8|d<<)TYpvSX9)c5c1-PM0AR|ee2Ub_u;|Ym&UGvbudCc`+9v|SEsMX(6xR%hICN~vxi+zwWy*%6?eQeO>nV<=?d;973KHB%_@#@_kXtF^zU zpGhiTjfa|*QZNw+?ow-Wb6Fhh@M04=1VmBGR0flt(&)5ZXju;t1PH)@-5w}pP#)>m zGd9LAJf|t>NqZig@QVIW%3Rm2ZorS)_~qbLLLm<`9Noas?sOqkWiF4LF&bJw-+b08 z%~T6x>K(SIM>s;ag+(|t zBwaF*kwRSZIokJ)BA?Q94P-s*2^njXrwq6l4|)!5VcwMW%Nk=eN3@-t3HQkKV)W+u zSbgRs@$A+032WsP#-j+{)#>X!+p5j?$I(mk=JH3e%g^Cj^mw#FpmlLq71Wr)%*Sy4 z9M#gp)>P`g(PgO|7-%SA%BWaO z(zQJ>PS6~Y=RG0KZB9U4S^bfPSK+Pt6Y3J@w=sw<_UJ@W6@*igx&S)pPYF0FLdwE1 zi+%_&BxmE%56xhw0?~vr!dGf#EBIKcx;f%xy`Y)hevC{r*3X||2o{fz^bHin$! zm^szhIr$jk_%33j%yMod2#Mdj+l6f}E+SSqd_ci&FQ9M*Peg+GjdXaAmAu%$cw{N5kG#}=TZT-@Gb4W7J*rX~U6E=A z96){m@waDzetUe|`g_t(LJYBEJ8|{=LoED&*?o}!;+TvaI(9+$Kcl9Y;sBiF!Sd0H zI}avZ9nAiCcL4loOiFTZE2pE|3x;JaDa*D4^eGE_NQ3OS!ItP=S^}Ah&=t9I z6~hT#%~}E!jlZdTll_-u?r(MYs|fXqL>sx_xTj;8xL=OLIy+F??HQLsqgP4Q58W?X zAFEsjt9YWzfabbhLcY%6={k>^y7AuBejPsaMW6jGN=5Y((%8|_Sw8b;a3%Nu&Q#tBR#B5=ZNfT=gVR5`?BQ4VWP zz=9yD;IC(TO;seMV+?W)DGP|(RKwd`@>*wBF^~zsd9v;0w^+5y%dwS3qS#u!uWSjc6R% zI5@x3USKFz{;Glb!$$-E`jOg+XS&VRnOJEn$Jo%-d9#coEOeO|{e0E95T|(nJ%wUq z=iVBYD|cw}7jV}SW962jL3l%&ao_?YCdBk98u5|Mf>wA(C=<&>H?bi!+@8$ZU{J~F z8>Gm@DS#0)2L{O1n?mH#47~EKt@x3r1r+g&;lTN4h~VURv?D9mjK9?uC8Ql>L4hMY zvAl9|r1G!_T05OC4*bTUs~>~jY|}B!r@zIJ#EGU}hux+lJ=k_N|Al+8v#e~uz?;$~ zg&6t(d*f^9CDOrZb@kT?Z)noz+{J8b+U+F*!AW4s)#bd~KwV{Tb{ucakKsxcF-QbxZ{8@8J?qkNgUktI3>B3Kh@LH^lj&>_4 z*0?yWe-%IBgR>j2tOB9v*pkwp(ivD*WYawZ26-Hjy5LPE2LuUs0+MciJ-E*87iWf+ z1H`he0P$?85^qV!M1eoyJ=RJ!Fm9RC4Lnrb@d&rE#EA-%q7=cIq1^ zB_lkHb3EHQZ8Bbd=FOv?XI!40zsn~CFF@WSrD!U4mdvsBL9RDvcl(cAnOB-~X^LMQ z%L{WK*4J$%&6`_O()N_j{LSYL*)f}n4NlNChq5m1Py=qwj;}@dGq9#4aSWtj3bU^ z=50V8^)+VhwAsaNd^0~Os&o6=`)jRzCaA9yrn^CqtWe}cTXaI5B{M92q9#+K;t(@i z<}{G>4<`6lq)L+|^DpK}o-O+q{txKyGv_EY5@bzy7XO%LmBVLcvyW>)A%$L$8+4!5 zOsIWKzs2uHg9{nx9gh|5TSn;aEW5M~LK15u%OnDAzjh5l6T8hvfr5y;$0CJ?B3GhD z;VZ1IXGiFP4F!RS{@W+lmR4T`wkqq^2wAu$)iSuJoMNF=4Z=7*{&?wsGwkhFKIFOD zhCl3+A6tqKlS$8tW>yG;4;ESB2SGWK3yBwNqsv~GkO`Z~F@1{~2CX7a0OtI``Hi#~ zo@!lC0&Cqnz)Mxojk7<#$Tpkq)`=t+9AF1u)?mQ;xeHGOrTWXSX=HZ9`Y1SzAj`aK zaFl~Tc;qc1c7%765xi#aew*yRZ3a}zZg6zU%;lu@kd5>RFcu4&x{53zuaGWT6Om`Z zchm+ewXl0byFb2DCX5!qLYuqU%7Y21&;Xg?%9$%_P`w&D4ZKakm%^9D3BOlq$^Yv! zAnhUy|A^IJLE28(2=9NeOx=STQ-TKC?7y!fuL(}Vp;^E&Sr)_Mx%SHOxesKY5YFQ~g$-vEDXNe&8v!>rIW(M6WuW`S zAP%T?#s_9MB(PvxS&<;VYVAdK_EBh8Q@B251d4rllA;TZ^lZT{@jc=RFJZhmEADxM zjr&D7YI577QUVr^SFZc(I5y=8Yl|Ml)PS@R7+5`RD(KvIH)ghFum(6w!(OV0RJr8P z*20IfAfHUsd32yet$0rDX)|o#xF#S3W>S!GUsP;CLB3a^qh`J7{*`+(+zxe;Q*8gnbtdZV_W zZ*+ay!*|-FW977*+TG|m56b<`)68`ld_>#uSF-{Xh@v;xd2>>@A+h^tZST>i8iou( zn2MZtep@{Z4X!d&Njwy!-|RBg+I5G(n3W$A;H&+SNb^p_gLDAJc6+Nc8wtiyfNgrwLP_h{jmV@Ccru zBkZ+=+vnYbKfWGi0&_a@Q5rWJ-1CVc?(_q3D>zcd%oUp!sMP%TA&VjThU7%xoC`s} z^`xfs632x4UC2x_g#@gb{z&*4Z>ME6(%8>3P(rl-)2>HV_f9=5A8fC*bd>I} z%lJa=uwT2l;X6shTQJ-FZTFnQD7gFSnp z?%+nR74#G<)tHy8z(K%{SslW!Z(ks%%A(DFsI;L?q<0x~jpTvsiMHEcFrnmrsyU(ONS;+h2laG0x-aKb1Lm>zUlM4We8w7cP*xUmkfs|&RC)C! zmO&E7wPoWk=N!9gG2v}ZG;nEJg-7w^w~ehT@}DLnNjTyR)d))%`yNMbpfPWLPesIU zI2#kAK|6bMAJ@it{9y+pKK=Dzap@JHidt6vUVm^kdP#HQ3E~>)jXfM`4_9bqK=+-L z45yA^=3{eZ=NxShr&Hohqgz8=829WKw=_u-ISn1K0jUmYbEWY(8B@G+j=rf`fw8J$ zqM{MAGU#E8nl|{ZxQt$mJws67^OHGr00UGsu=VpFwf)lm04#{-Wkmd!Z8!DE;ED6V zT^Argle_CjIt4kmN(P=g`5iIvXkOx264p3z>|H@r3*otdBO4EYR$)g1EiYiDvcKQi*(R}DFs8X zf3-ELBaAZMi6FQ<4haVx3v|U5- z>m07NALt-3_?-aL`E=ji4;rLcw={6SfB5!t!mziS%|YPfEqEjq-66l+S`Avyv$9~w zh;>lN*K_2BITbmdg)zYPdlcUSt&l||#YCTSiXE2@Etnvu_k?eXFsNI)?5!&hVw72n z(J1~^)PiF4L5V9Qmca5ES5KM;)J^#r!~6qruLeIXd2d+uKn$Ws8xoz%RYfd#SX>zG zY550gc7~iPJZQK_x5iWeL`hUtJt-gEcA;SXWel3KoANvw}G{Y&%V51jHDI+{9_$QR}>~S@mFm+OwV0GZPK- zGt>N|upg8TG;}-yfx>b7F6iU<;xfceFc&;jdk45rLhEYEWwOB zH7(>sw4}Kd!6!)Z^#^s7HBE-|9eQ3)_h7)gAXlg7=E-t6m4z^R1M_6BTseKj-({(h zmYE#yaN6-f=jz$qei8t;P|^UXq`ELO=)V~6%|wy~CR@}xP~Pc~!^ zE>tnmk-k`E>9zm`b_{Tq2E1UkE=C#762T<~sgU0ILKLOo%zLav1lunp&5p+x2WY}j z2ryR6M44bPx_%{t+>jGFrrYr0Mkqiztysz0Pm~%=5JfILT&i`QdC@24IveI7LOex0 z{Kznoc{3Du5y1%gBFGcEs3JpxS7LKt1?#J@@Guk+cHTh}P&JHKW@6o@pFPTmfbxQT zP)UyMQYicdzXF+Qm4Fnq2KMdaF$BOVka+-%!t&pw|D@pBtAWRKefSk17Z=)ts05Kf z;`&pl39}3LRP^|YzawUaf7mdD(JAy9uSDu388A938!*M$+;ylWnZp{KHkV>Mjg>Iz zJf?q=WZ4PPujELompt1#{oT4oWYQoZ#D+e)CXDUj-va1T4*u)$hsNn2S3z*3xD{f# z80Rf;@3+pU$g5c~AdNrgD1q_4Miq`bh%IsV5?;XhN-i7 z+WUJH-gm;$pvym?HMF$hVpvnVjjX6vI6s!8$(}22V{|hyb)zi4o6NSxWZef?lmvKH zMcN@#5IB$+FPZCaLrQ#kzK6}M^`fB<*GPnkg?jgG7f2g79g0UpWlV52@d;Aq2IMx5t2;+v0 z^ShgxM5o7FFR7RZeW`VZ^u4jrp!M{{#{RZ-YmHOuTl0umbN7y%=lT614pzT==H{IC zHoxJM$N~K2EBVx20RUNb;ApGpkuDFlF_XWxUhwj)D=8mA z-Xo#pkc-s$bgOxwk*1*sQl)Pl9#Gx-!h2t&6{;>mms;%5OPDjQW-Q20na5QieRChM zj6hBHl<4^Irwm2IuCHuIA(4;Cc&c#WSN_rQgllKG{G*%1`B!7@LiXmp!LJ_n`Xpy| zq-B^Z4y6_Hjhnol5`cB?nAQkiLLd)MtX9vW)^AaB^7nsj^9Uvx4K>HDk* zQM)oupzqKE4)5$H4)0;$HQdF<4k+8ewQ#Xb_RsTJia)PvJPL!78$Nq~w{loW@*}x# zgnJ(Gck8>J>u|F^VB0=4+vqY22iOX~i)*hG{_?EO4|i${4!r;y<=gsEqNXP4vK9 znpp1yY&ZSv)i`t^&^CT$UfO=0`0(X59*U{V67;IhvBqS`6! zZ*M*{d_e&#TYK{t=fKmad%(qePx^4#5BE$xzXbbdvPY@E@|ALhetqyTu7*@@l#gI5 z@89vb7bzzevYx5HiZn`Lz#p!T(>ClJ#)qeQPY%mTD8NMwgRIqFmARnL50)d#di^2D z?S$a5)zg1$ATlI9UM_de-zd?L6FJC4NYzOkn0&QaC~9$j@X}$&QrwOJ`0GEtZwKlo zH$I&7EwJ9c|8s=8%?<(=21l9N-CqA4e0#ytvmcBuEDT5=y7ck~-)Is2rtT|dku0%* z%^w#AdfvKeqOn8sM^F8pjUot{SL{Z|0gw<-kYtzxwmzZ5-?pXD&+=#NV56YJE2%m5 z_7&(M*ij0=Uh^N#xP7hd*4NwGy4^<)e-B4<5rBa?I+*8#Ns-+8zZC&#R?IZuT^8oFL&i{+@ih z0W!T_RIRq@;le~uCVd&~*4-&A2LDy;F+~yE(4wv%$MwIk0Dd6e4goz(kK(E{{QGII zMNAVlQGj*AzV1KPf$A!&_X@Endb;=3x-jW3ieo9g%bEnj@KG*=EmTc5Ak@6fqlf>goVm z_a=cpXG6z2cgyxqCHFJbp-P`$k5vrqKmusnkw2(g`$mVpnD)NrSN-K+G_R<`7l649 z$9F8c1PgdDSg_#o5Xq}^9RGEs9Pz)-Y_9V;)YO^QdA&TCoez?>ZJ@_D&d?hmIsXLp z83L!kB=-;a_hRn@u5t8)*-y6$f;RiP-@*Bwib2gn(`qZ_$hkJ0%xN1>n3VKKLNP($ zc;Vu9^gJI&PeqUR7}2cIKT>nOY9S8BC@hEsdLj9kwXG%Fe)R8>Rdlx$4{mVr;uwLp zY>aj6sRTiEXX7w6KTaoz7gzgj_7!8&P8h3#SFcPmk^X3CE0UUrjb8^tf8c*)Kxg1u zX5Z$Ngvc`u$kK^6cj4)xStek$PKJToVqeFXPsG(qJLZ~s6#~h0cA^&t8aM?j*(KAH z>M=_`ruz91wq~$-LNK_?*=Xk7Yc3_y#4+ zu=y>&l&CRVFyHdz1R149 z5M!!x18ToCS5YU_2dVvgK*dVZ3BLAd`%=hjU)R=KeQw5>F55)J+l=V%D8jXHJV1?cG9} zR`c|$;xG~;h^`E85SR;;LReEMTOLdidbO@csF!LK=nnc zQPIbG>Y*qsykpY)o#k8ECsfLt*RT&_bTp=hfSKSu$`Z#i{DyZ+%^)grNS&i8__?c? zr*OqWZ385Ly1Bwx?}#GK-rBNV+ta!1uofAK_%}?H(Br!?L;rXE0i5nlrk|95ABtoRlJ02##B&fiT)q{!k*7*+aD* zC^?^ivQ)`?0wrMq@n%e=TNX5!?t&lTEg-_H!u2@f1~}vy=*j05b|fV@iHIJcDCBV~ zE9!1;4M`?mt|clfQL{|RyyfD1ti8}GEQVNQ_^KKHqrc~M9dDVxpnLE;G;-9tR=b|; z?|)bBj%12Pd-Jn&`Ka_Kym*{O2ICfMR+;hm1%^qNEoDyAovQQU(vQ+=CSLE4??|!v z*y81Zz~1(gf0T-K%{PCKvwL4s}hY3?aHr`T~`Vw0O{ z<0Rlk)<}`BKLLuHhGPka#Ll0-be&|x8)H3iX^Dl!Jep*9TmR1rU>SudYbWLxb2Fqe z*yy0SP4Tr%mdopan*e-0 zkhtd^C_tx9yT1e%*Yt5eBKzkKlwQ8nKPOJ$(Y`I9LQ8XqLK^9e8@V~{!;QJZzvqi` zca)O>bQb3-NdP!p{+}XPcTt}ahXDoIpwgLb^sMpJ4cZS;-R06}YeLznsCID>oG{o|FPU3i$Gtu0Ntq;ZYwGJD7znW={?Lv9px>cY@)<3IIEdE6PX zSO$4BD2GEz;3;a$4*R`xx9CcFuk*}0=&Mx7d&0TXIa@;?Qg?S`F!PT1r-u$?@QjVZ zr^MD$p1QFOa_PwrFsyBel@No9$tSD*Jl71x*L7h}X1|>DmC)%p@-lCJn+^|h#c()Q z6X!gV=s-!d^}aj=3tN5h!;#rva54#GCtqQSI8(|Ubn_RfBC3j(g2G+c$H=P2R#o?* zAS$SWc5*Wxy}q_}3b#B25bsH1y!ud53|qYS(^;LBRcsQiT~@hW51=^f$p|swfe~K% z2L1&9NHIr-+t>D=>H&Ng(wzg3j(C`D(p)|nLWi`t&3z7Pg9ix8HzQ9ZeYpD10=kR4 z&~EiFB&>BR1Rv1bXgub@G7sHeJtKyXFmZYaa|lM=aDT$)j}3D2>?{Q(`0xWd5Fx7t z$hVC2QE17^`3nCR_>KzRQ_F#6Ie`IzNNdnav>ojE?l=Lfp|Vl8noN3y5suLI zJ|1L0x58Gr@*g+Jd;)(~JGvoFp^ZlTkin;57}FzX0+`{bgLCoiq&b@~MBm*LIeU0{Gj-g3?eb~u z5>InYj`EL7u^2{fj@(Lxv%^>rOJ&LFxDEe(@?R2ciKeiC;&+i5H5vEK=27E*}t;One!H5zos=3Hr3rFUH7}%cFqS*X}E@t@@U>VmtIK(3QK#hcdr!X8f zNTvnreT1gb=XE=qHI;tmCtO2;2PVpcTWtdTIm(_@T;|)Frf+5KrqW3_GObOa^_#fr zC%cCDRKINmK2mpTk%|Ct_%qPN+r82V+|y8^qVMB4HEjm4^BuNI8` zHgUW&LDe}KdBrQ~>AJ}w&;@Ktjg(--Jb?MD0443LXJ8!6hOI0?0_Rr5YH{eekM0`O zucj9?bpx$4ap*p=nr<$oSYrmEAoo(x};)P=}iNtt;}ac>f6W z;A3G}-xc=DoBA%cd&qleeSyXOwz)mwcugr1o0cjylSiJe$kseRYzJYdpavCW4!e18 zLnt%R^UKsAvlJf#V|_VGi_DE}vf`2U-pmaR%uO$*6J&(y{($&dA%z`AsqBQ3mWvZ4 zCe!ymHE-^%JXb+ra*n&yhB8N zAzrdnYSC}I7>!JDimN8I2Dw3o4btiW6x z#kv~2z%YNflO+vpCCH$H4{vqAz}zk)aghO4o(jG<--99`*(ot0L9@J{4s(?Y6tls*f%*Ya;9ntykMJk7)pj2U+Op4HjG>H(0eW=V)7ZFDSLu!2>v6 pgC{SLu!n5j%SpzoM?Y83o-bX`9tM{M-;bm2(kMpN0DVZ1{|D{0m(~CP diff --git a/submodules/TelegramUI/Resources/Animations/ChatAutomation.tgs b/submodules/TelegramUI/Resources/Animations/ChatAutomation.tgs new file mode 100644 index 0000000000000000000000000000000000000000..3e386fd63ec1b95e18402fc7704b225720374214 GIT binary patch literal 15824 zcmZ|0V{k6a6FnH~-Z;6jZQHpqp4hf++qP}ny0LBB&J%Or-+!yNYO8kVQ&*qv`7m{A zrn>tKK@<$g{}vedwU0&I(MZFd9`ZX&##ngw(=_8wEdil=sMllq6T~5Hq`+ei` z;C#C7?FIThCiVOE`rHayo%c)e%l9XC=N**x^KMk&Yrjk2^SaB<@AcVkR{>Gr`}J^f z^>Ls|E>G956w&)9uZ+X_^qhcT=j%PF6v|HT>-*ivSH8^$_$?lA1iY2g+pN5`YhPFE zU&QI_eSdxbIxVaFd;fS|#QI6m^Ivw{=V8j@-E3K1OeDKc!AefoZd!SYTQ7)-A@w6m zi~F(-Xg{??2zW^U$)CK+Kk-^~NUtM5MFJHp1d%JVrw8Y5H^upWZ_9{Xsf0F}c`B1L zKE6R>q2@4i^C=T2^Yh#?6S)3fg}!fm~|534~6Bf>&J zWaO+mr(cIozkFqH1pM|RJ_(av&(qZUdS_@RXkH(I;+oAIdBCBbS!r5C{;vn+y6v?i z(^o9sw<@-1UadFSFO?u07Wsw2m)*2xeC$XRG+vC~7Qmz%v^R@eAz!0cuos2jdhUP1 zdkG%-R$M&lZ^7qaK4%;0`Y5)FbFc~omH+Vi(JzR}me#W(gPucK7B$C&$Wn_fcJ9Z| zEwuP>=f zzzMu?UktnXyqR3$FHvx+t2SfBJ5Hw8n}=^{%3)s!U>e}yPgx=x+o0z)5K%Tu!0d1& zu&eC%+|d1!IJJJcP9&`h84YQ}KX~SXVr-sxPyx>G%B=sX4)|DsJL_MsxHS2Bsge`; z{$a4F;=7Rf%z9zYt*4?B3}S;#soyKv5IYB#_9&GOHDzSE$Ci@!!f0dM z;HoJKi&Pv>Og*r#>&6!ulRVSQ0-S?*V-RrUfQ25|!7*j)?wJ@vi%V#yw7I1t527BK zP>oLRnb?gnWn58Z^R)*S@@BasWj?$y;}m%n+&{qG-nWw~0pE|)+wb>l4mm#4lR=Q7 zonoSE*+&sa3o}mw@v^zgflDrr^uw*cmGeAxiaqFD`nUxYz0n(W=9tB=to^s5Q;Nkjj z`BC&@tTVyk(eO;>y#^L+hQF{pAMe{{ddj^conNj6f)Dz>`W~tZeZY=>-ahXK1@vw~ zfn{xf8LPlOc3*f#d%XAkdOSW{<$jFH#9>z`#ht@m*OF~N2m=~|iq$cZ6=-%KCkTO6 zCcU)4r_fNALL#Qgkw36!YV9_>Lst6*kU~T9B!wZ-5jaBG$fyw{q&M}1JG-KOo8jiQ z0pX6!o5^yfdO1_hISd>k+3?6ZiaQa+R2EME!D)Yoj4Df!Aw=bO^!L&I&9eiEze}U3 z-NjZfEY8sj(kcCu$zT};Y^einUZPmG`s-|K|_3W0Gc{tYY9#>0HBcsG(=yutv?1X~ez4Zy0 z5~^Zp%P}ZPj>0IDn8^{PlvV6axEJ#i9X4}XqZ_HkL>@UX=RxJlVBA?C-C-#RPfWa0 zsvg(B81obLAQHB;-O*MGY|E576||$MA@%h4N(#+b`)0BREJhewlQ|(ru4^YRLK;1a zlGV<-cUmfRD{p1WVmQLHjYO1zOECGtSq@{EJavDYM~a8AqlV4tB*m=p{u-v+W-pCY zjfo>$7{Dz_BzCBSA9q&U@ggum&PjpWnZAc`$jU565taf9%u*1q1?`35_{Mj}#!e!# zWV|WdkNaD1mFt+k18{GzWc*W^8f{S}mLQtCni7^=B&q$4dVf~vI`Zu#3k$h^{n1c> zCXbR2Rv~3%TU80##wt7 z)nu0(I|24*dhxx$U2!__{0fm&UAPK!OWS&QYarnWQgS1+&UFEvC}kBpp1;SZ>BGbIP%@<9Mr?n6}Sn6lum*o z4jE3eHG0nKgOm?+ENLMtB zdjHKxL^!H41%gTpn^yq7cuGAhxct)38Tjj5#L)>k(gFG;N@94Zc4LURW4(M>q@4TF z5N2f<;*hpJHa@!IuQh5+1j}wIwoSvhw4IqDN)0@&@%uDBOD)*ShwE^L@(3-$hZa1f> z#NUk=yM3rx58F~qh9ullf9MT5Ah8`v~>+%S+yqpaMj zi4{E%;hXN>Re=ib6S{Z_je5T;JbKqwQVQ8&`{x;us*tmi6JyiYaPy5^*Tq1zB})n1 zSd4#@{)vCx((gG|7wHI;IBnCL!?g!0v^S}P2&;{LAC7X|{;VrDevoj9Zb@XaiYErg z7;ZL(4`20%iiIF~oDMIk`5%jKQ)qmZXpa$crR6xJ99k2;c|t+ws<`MGjnFK?t#j+A zu-_9EeVfbr7`AYg*_;1v5Gjo*Usxn8C!C`?5ecI4Al*o7YIo*h(z?LJHJc$~9$8wU zz`#RYOU{99{`Kx`7U+>6rp9d$3f~ITQ;@Sm<-C4y5>yiQ*4ddodObTS;M@#)C?Rps zWpF-ZB!{rcT~rrLLR$od5I-z2mF(4i$HlFLf<5m4w~q#`;x^@J8V>;;AKfTt2~&(! ze6up)lMcbJChN^G)f{;o(87suZE%3#;{`PvQ;b3yVSBf+>u0>Jj1l(hP(}*GB!=Pa zA7LeFVC2KVMMJ-mVZLKfqa{;YqrR_yM9dK{pd@AQQt4>VV5ES54uk>ZEP+*0|Izv1 zdA_jf%Ltr3;6c=}9i0sX{3aV@CSqYmERHFk4gj6GT@?{5Vac#UXD=nvnbS=Pkhn0V zk6@!(Wa2(MP5{R%lLt=8%0P3T(-<|GFWhAYUwQy+MID(ryzh^WQ!X>3K|IZ)wzQHujA3E^dS`Ny zPoV-f*Jlpkvw{&Z8`CV?GyXtIWHTRn+?l&i2FmDSWX~?7MLr{vva+ z*oLJ%Qq=`%gPU(j_3pkv;}belE7ENEN7CoBDkcXW{3&&KsqoO4s)cL4oasrBbB{t` zBo&P@Ui9z8b`|izV(uy5huV%*U9+T*S50idKCKu~-%Oz97R}^?;xh-KX%>BpYT=nH z0z)`JOg#=GAgs#xo`L#A%pH4NCMW6TV@mK|kCG!gjXgXE_d7<2R24xQmk`m0;B^Ch znP#RZ+GVC~mM3l{PoVexe){qy&+j!Ny@+6^%l~8>i~KmAmnr;v1>C$YNiuZ1+OY@k zc|o5%tFCJD!->0H?0}OLB|3zK`XJ`A##BzhNco^!Q_?lBc-B7q#g%Zt0LjQH83KE& zSVjjnHoQWT_FsZ>ibWOV_|lFsPiCs=;YmXBn0hKD>epy9hY5TQs3dt?b-3ixE;RY? zxKy4I>aYYId>Q^hJW2|cLdS#l&-SpHe}JY`AETr*(n`09_mako=CJ)UrxUWjbx|J} zIDdr=0+i5!z2Q<#;J#1Zf8RwDbTM#auJjBMGMw;ijaK)cZ|n40zHEeU9on~*%1(~L zTQtd7m>TO)IM?#VWiKnA+0qNDHo*O1Mwet#29mOJ$4LE z#6caG`kpi^u$g1&0QSs|R=0{Qr8o! zOpRX8>T*28P?uco*#(g+2)L+$2Pm*)R&>Ekv#Ud8f77qd54TVVHD@b?MN``$Ms%|^ z+AmmhZ@@S&>YUlLhhjP@VMD6R+q;7U8s?{`P9LF9hW#xEX!9Etr&+|r!aSaOSVB@1 z^P1X8x!VttbH(-SFo$#{9_bRNd}DU9g6Fk^YJp+f8%a))q2J~QpD;n$#Ip(JlYt2)O#Y>}@u6taCM~bA8ybV`Uv4;<7 zDf!tv;&LgB6oXEbJ}Qv;ri0Eb@I~@6%b`z&a-h}^k=Cm+yOdZSqpPfJX1?%HFnIOB zbxLqV^WM|Mms^}{{=(ws8IcUo;@r#FBUlfQgIlp5KIUFr!vL+t)6IAQ_-ai=|Gm+j z>PtI`DyOq7;w1G66b}4pM^mZvm(_QG8JK;#vTO&_QTrwi*d#VIX$KBfxZ$ReD$0t; zD(M*u7IDsXbNeG>%o6X&e`i~Y1NV)LH)-w|+I^>Knb*NOlf-FAhrIH?=D-!iwxxC= zGsXhY@_%2UiETxRv^t7pC7UXxUqTVD=D>IlYQlQuza2A53Kw8Dn&pWOeoM`lLSfA_ zSEA$k`eW^r!l_G^;=P+c<2k6TSQvrxvCyBs6w0wc#PaWHG50CWL{fmHA(R2oPF2jZ zx{C){vi*aia4Pm3b$N2?1?e{l4-f7Pr`UsFl6*|P(4;G87ha`y0xd=?j<)nw4Mk+N zO|j|tMtI}uzj>d_FWBQgsD4^iC`?)}(V~fX#{jhCkPI2um6J$>I*y@2|J9(YU2W?| zMvkF8N-0hY-NjeSSyuNDKWkVwQ@98m6_@rv;HFw0e+#ee0WAUt$y>9TB)DE49{|$d z!qNEZh18kCc`1|+f+@5diDz8&c%IIL~CLIU&Pr_PRf-r!0 z5;GR^yIMpmt4WN!;0&nUTg5-F{KMFfljVk!we(7(UgXM2q zu5#vYOZsJr=&ZV=5dnLsntG`tpZOkD>&;RyOi0iH0x+x!ab zU;aaG+IK2^**rf`W}bjx9fOSVS_ZZGmEQXBa*6u3r2H}*pgWQmdW5n{2p<6NMaTG; zn*|3#)!ybSw>_6~&pQ`X#0DVInMkl)92BUg5W_PGJz&0lw=Mhj6!u$i1=-O%&tUuL za~Jl~yR5Wc+HX|udnN)Ilj3<7zUPc^+90i&1(~`2r;H{ZebZOS`)EuqdCi+@pb?Xw z{woKYfZ=Dxf&!@cwdyYM6{amUTCej#%6oM%kros|uj}*qI^)f+GbTN@&aozP=-vI( z{Q3*lBN171flaw2Myvy&R|IwhFn@hP-Vha3EjUK?f|n+3R7yf+Z(&O`OR4{D3C-QW zu5DKg-I+p73V-H(4wau(&IsF8?38F>$JG^M?#{cGaolYs%wPGh2Z592!rl%Pm0tO< z7kBP!64EDss^ox@u$8%9km zW+lNFd^JG}6AE(oo6((0absE-AunAC?KEVTIyMj1$!x1s(cq}kWQ)m+%*_UwN2NJ- z6qOqfJ4zCPCpXyg7ob<7(^cjQcAatfWhMMy!Jcer`0AFWDg$2mmzF&2yKQV(q<-Vk5TZJY zYJ^K4j|fZ2tV))(^m&;a2i2@smZQ{^%3LY-$dm-njA#h?K;dPXoM3!9LC`=Cl#3o; zF_aYaZ=8CEgNDDDco76%VX@O2BK^M+&$mj|i{94*d(vqgCo2evHo*})E1qtam zPxcS2*y$X}j3qKyxYfLkednqECuH`Ggzqoq^ex=%TBSesp|Og}F{S1Q<0WPX2LVq! zUhA*GSUNvs905nm^>&^#3g*swo>Yolbyqh^Io|q#8<|`vB`KDM`!S;8ruzHv+S!r3 z@iSdvic$wj8)2rDd7FgD2%VOQVUa%-vUH`6wlGg>8ForB z75XjbBmW&puo3m_7CGMHzl*t8m$$7WWzrc~<$8y&Wc6Lwl9Pni+!7-fu+#!bQA3Di4+FI@KAI$*;OzMnoZV%p zfDwxn8qs%5Z!g`Kx?*dN)qgGzkH{hIZvS&)+NJ6O!jYp0sW!))cwhFI2(iPRBFsy+ z{xc8pz6R_*L24tt`^f$bpYhG>Z?n0JP^lja+QE;!A)8b^E3lrA9%50O-? z_GXRaI*6G%0s3s#n9ZNWs280qo=k(tVy6Si$4+8K^c#(LD1Vc~o!FK!q|>HyYy^&H z^0ETctEaUAVA{)LGhnE&M;|#5Gfh1Eq5v=>)v}7rZfWx&k))^W%x*3aDU#GKE=p0y zri94xm5VaIpKYlMqX#3f7&&3om>^7BYcHcJuvdP8|#gsY&X7_ntunYW3ia*|9W zs;Zx#SX~=B^>jK(A4s~!3(GDS`2RBZxS3%DN$KH9NBaW8WNtvsQ*QmD=|qC^8{(|&3+$s6+`1z{nmv;E7peUr^+!jIP*o(1ACuzQpVby zUvuIEsmPR1rl+JaLH!4^Z69(xv=u-MG z6o)%6OOb-~EX@i}YqXtc&nywx7?RHzW6!E0%mf<3PIKRYI#;_>SY%~q(<6m5h6Qq3 zkDYtBP~j?=^-UhlItY#^p%#_cWu>f;+O|W}dUb&SkSH>$CgY*0YVg zCWT3rsN5EDGSjC}NR^WEb%j=>^isJ@t66%$V*-7TC^$=RX*^}=UR-1dO20Whle zs}bA(b-%64qO?623rmi}%Wf*iBB)QdRkBCtAf4GQF3rm@dYEG;uc^7;MZMI)6!-|X zfYSH*%`L4s7Sq{S3f`|GI#(>stBDDl&Rp+nM_m-%S;LbF7SdF9S_ z7#D8jg2j^nX20>YO)}Vk>Go`^)KXD3+~vbu8KumG)B|g)lWFhh@geCie8e9l@`ktB zn>m`FX1H`5@)dq2DAA+l&k%Fhmg{{jLt+vaU}EF^v*$q|1}!IJWqWlB8Q5q7WHw}r zi#-KkRp`3C+;NP!VuoCB91)G?C7ZJ0*9;XRpn3kSC2^i@)iRWnms7z|wJ6+JxQ4GG zG1kc3TNmnN_ zMLcL|_`X&eJ8^Z~>0IFi0`dv8JIu;fI7L`RCC6haVC}Gb4j~DJ;n9!>rGuKfVJxAk zdpK+g$zJ3;Mw_ffFlK`AD2XSsm3lW_6s(sk8gDk;RC(f?0Q-+5`M#~HBPNA+S<)Dj zqog+egY$wSjs1G(3>X|qmRj>Y+54i?eDrh91>1-K(mas_6x_&ICwy76k(P+hX+Zb| zu09|wMgqy6y+@+zE4#~hH`m|?V!t%+9&U!%#m#BVt0daSlC`b&<9lN4r$0OS=2wisdz zvWMxMn59}7OZb?Wr@Csq-X|~&+Z-Rq!sZhZv!N;R5|KhSnu%?fWqmhsY-Kf^+`6b! z1&tiITeM;v4v;r#Wy*Drz)XR!!@({+zda9+kBgU8f+qH&DxLAo!rak5Z*QFk9_BOx zy`RTjSqfSRT*c;ky6m6Tq8sh$rX1f8`{{QjFco(_~Ng@>&e|gmO71GPdbr(}rwG@*WWroelIarLHCr&P= zh)*4^Z`T7XlZMR%^)zS{zLzm0?ow8Q`HV>gN8*S8?G!4i*w%K0ko|O&+Vt_~?e|%) zB1T5=kcvt9D@qwSoPZ7`utTM|f27?AJ`N<0!#Vu}n8$=@WSS;d%ap&Tt1BpFzD{!# zFymO{)Ac{lN~tD071Fzk7(2xcGJz&0aEIcQ(c2W`Ik?*_D12IRYnlkA$Hja3yt$}+ zEdsIYK!C-tq5A4q>I;RBRA8O;i%PlP7qcGzF<*zn=8IRbC|nCe))xo9+&1p<(}@}Q z%gr~WWY~0W@kAqLC&y`KVbkU6*88;rp_a_90QGyah>!JOQ>LE|;0CmHqxB ziQ5ksr{mQM7sub{8CfBVU?sr7nfdB+hhzIuu2Ee3`?229P#Y7_oXl`$z*=Zn<<;7%b33ip}4?V~t70A-!Gn=N6|nASE1+{lI?$HmX=?y}2Cw z=yW>Reg<*zhMX#>x7=$yPAlhV)MQcmn7_C?#D`@75hFdJd0$)$tAGOn%UE!ou{ep$ zWHA0axgGxR#^OH5Z!F{SjsKj!=7aIRnZ#?1#e8_~KbQ*uD(|?eZ|@=t^cx0*@1X-< zB|K+@z;7vo(e@T$|?T>?mX1kC3AW=d^)8 z7$J1B0WnU61c6qvlcF$})U|B*%Gz%7mV8e=8XkW>6EworGD4bfXbFeDprSpa9>w~C zTR=jY#Yd(7xq#wJQ1bEO^hcdul8hDo?e+((xYjlQLJYzXca?mY;Bs4aCmK!LO+o2g z6eY-I5l_`kl;DeBxRJ=o=|m88mnU8yhTj6r#)eq)@Z;=0Uw>XMl?W}fzF55+J}+AwWp-KQR28ShZcD5#i?@yPlw3tYd<+W z5TAO4e~K&4x9t+*_*ES1%+VBDHC#e|IpEZp)_AQ=8Z{0m)&>e|2WV?^FxKhb)r0EK zg6*X?Juz@JYf7jTB}^YN6E<6#v*pn=naedQL|1wKdn@?f+Vh#UhzR&x6>X64Geug{ z&At|dJA)tI&}Fy-C(ql=j7}&@P5Y7pl(W_nj+BIUgGfyYi9+%_V$xzkKz2<>Mn=2A z_~!XUA^ZR1dYszUGQ7%J43j1BGkSmZp4G1AwMpz=cdrhmc6KS##waS`5#>#+T`y$b zKVnb@ohtf!uN1tu& zaJ(Tg0GagS?SQ3h^$)^%#-2*rBngr#z}6*kQb%J+DW_0yRtqu3R4lSyE09{SPmxA1 zR3fOJ>8!n@^V-2>bE{cu0CT`B-RqQnyVcgCti2mxb<5s{KdN#WGWD|EZncf$yW2Ur z(zEZjb@o#En#2Cyv*q2#_)_ClM8HePx3UvAG$MD|wqfd?Kn@q8XwK2SFvCtiGADwGDzQL^?I!SJn&N7}jz37iY9hm;RH^+zZjBYyU+T zp{-eei<{8KOkjv>zvztFCPKOuwDwJ0Yfb-!viV+NYeIp__}o7$n@#Y`MWUwpf~rNL zuK5D1Uy$H#Z8ikeeTZA+xOzSWuh+co@BA>E9`-7|tk!1z%Wn(K|Aj3U1O#o1g{hYO z1<&iUL97OZLD{-*Vpq$BvGZ*&IQDzYcy8jW=ONg5c6zMpPnFme(V(79oE*R0>H40; zr|o{d(y5{=xzMLuHuI}8qIW?m`FR1qPhIBVfc!EWB;K1sJzJZSx_}kw87Y@PWp1Wr zjGu456oWS?9ME?8ATwxD?1MfFy_y6IVL$pA`aA*yX=V=CERm@tg9LdR81$haN%!OP z!F$oc$qeCPDJ2r2hC~h^rR`SAG3mDJsg)JQhCOu#%7=QBF)SX`%g;v*xgb-GYxA>1 z@xuwPVTrk&NZ}^pjsLub?x&H1V_Ey*_eitGk)l8QL!iu=Zzh9CeXQk4qD)Ef8tRq0KPl!*?sXfvF8Ar;2M(xVIP9D&g*}^7<6J402VJ z66c6;LSz{d@|tp_YIR&78~3pdVPd4nu~Cq&^?TsZOA_&Uw&n@O<;xsp4(!DM)TMz4P?zogjpFJOJ|y!b?50nBuRwZGa0x=5 z6cDFERMYZf>#08m;p@3r%7W(bstsCTY88nE*qY3lEH&BthpD=eBF$2sQNFiOQ6BX> z$g=yh^HNrFM!*U6(umw}5-lm%tIr0m;o~lRE4i~?JJK82+n1Jlab*zwJz471ue`*~VeNWYhw65t9{Ua7&wamWQk13hd??cW6$rVvkCnGP4zcmxmgS zBDK-8%A+DyT{(BH+7jEb2IM#p9T6i>^cRlgS$LtfB)KdH(@4L)&u>UrPG-)M^Gg## z`>XGUK<38VC8Zclp{UPi^5*^Dk1adViYBQ&Gg57ltGQtdtWA1JuBE@A@!f{ zIOj+B$~yLub&e>&;At)+U;oc#^|hwiuFvc0sh;ME26Eo$RMYYpU5-#Q+z6E_6aebu!L9@#QEbvH*g^lqM@IAyn8o=Kb$_AxO@ zT=y+$_QE>~26Qg@db#c#I!v~(J{NknL<7GXJ!&UUzP_%B@auTc9E{GUGmHHQ_kRU8 z_D>JI9Org%UZa(&L!hK;Dm+5a+x*FT7kV@?mMqu{9uw0xeyf~HvvJ-zShGWy^g)!= z>w7;<%q?sgLXw1`c=!v!3Pzu*ozkoL5v5AdoaTGS`t5U}7CMOiB!F7EGwoPI!rQ3jd;@zO7=JI@wX#DT?LaGyvBx6i1_O-WGo5%M0RGl`3+P zn?(|3F_drp2~b9C3)3?vjq3?sYXi)1YTH@Snb}z;9!CidC_SC|F5 zu4eMG!czlGgz=);Idd?-Q7Ilxq>EiAFVtjL=OFCP3F@R`B#;kMPJ*&j3d=m~NtqrD zdyg#+CZI%(T$V>&_7rc;KNJ>$yQGa$p9Bkmi@Fx%Og0!-hX24sHg)|sl$r)D@Da~ZR_hd+h9vlkqh|A9CMjl$`Aj8zSQQRz!bB`olFcOmYAX1eI&^wlS2_mRO}EeS&m#yf3dm1v1VD zALxDF>ziy&9_Oo8rdJCuRwu^;ZG**i92AGcw`H(%9L6sTb*siwh|kVsOZ?TjF4Y4Y zD#0Fw0|A3r;jN?cqj6T!%FJXm#_@R^aaCm|zTK0k4*3cWS0u6nPV$L2np!2pG6$%6 z=#U^uYUA$sDNVWKRnp#}obnQ8a37T90vgtrlTYYNxbQccf{TR0)~2PtnsHnL*MKSa ztb;KO&&5R2un}8T6nIU9N=ro)t@Z_5#WNX4&TZS>v}+FUb(#}9r6#q5-PVQhrM~@1 zUC4jzY+f#)o2uCJ(AKYwYHaptugs;DQ~e`SOmmJ)f2oP7 z;SF3(-r?2DtKRf~HA93f0sH&W95@I*c@J!b*MxJXt|A}B#p7|D6T2?FxAK#=TsWrS zxyN--g>2^~Z#sbqt?*8l07o^^|Ak~>2-z-4+V+a>W*@OrbWBUyIwfxYf0$XB|9{X8 zfA-PBKgpX#B8HBeyY{)nra>tMe9~1T2y~}_xqjY4R7f_;y1}z#Zml^#<_c-|^r6y9 ziR4P+vXy^(=FZqCY5OZKbd~RBSTP4Eo!TXwTvxm{%3l<%44mzGRIondmpbSM5)+=I zSFqRymS9znvdDb%<=L)_WF{zh>#WJ~N(Pz5DF2NUXjgGkv9%OJk|Pr_!5B)>22+|M zQF^$kRwwK?#H3~);qp+5b3Nb#XHF)ZGGp)+3+br@wID{Yv6PFyBYOJt14(aLS(Th$ zQ?__x02Ub^G*t*4zeKPLgwTaqDpUkqI06djXd67cu*3`t%+*MhH{SVJa8o#X_hcZj zbZcP;HH12RooYMOJn6y*^oaG7DJsaL&0OVO8`=LyexQV-FAwM_n{5cj zXegP@(Bw*{iJhwj6FHuzQ#0*&X~}QD8WJW;E%QBYRvco3&E*X$8@IlsRVKNi?NStC zXi*G zt+nD|Y(R`v$Cc(4r&kzK@e0>6K8cLeILN9NsyIFzvdi|xAk#7ri4dl@65WD^Uuw^R z&o-k~;Q7lN3M9FeY$MdY@8DGcYUKUGM&<}=s3%N4%yh~h6o4=g4!UcMa3}cwjY5MI z;*{?oNc;@L3wpxLA{1S?ETk!l$D$`%R0`_?FzGEeMF;Gm^7naNFrYGX_uz8{c8xMm z5IqBC>MquNfHySXOJz-#^jwCCh6g|4F2;+Rznyt3%~1ERBG}SEByjyF zW&H(y9-YC$9-UI={wc$(ow$=Srsuoyf~BpAP!<_eGvhqS4TLjV#ur{95nRZ1zsV1hNu>EU6^Sn z1ciMn(jwDTK%`5Rb$nA%UtHwg?G+-006T7 z=B{sP#unY~d9mu}EpJ!X4w9-%lj6)W=llCPex!m!)IXw10qvjWn}c3~K~e@8%bzLr zL?SG*aEb;;Z!9#ZMHtPFgyx2h9a^@HmhpF0)`EE5uhTvs;Ix|G=kx6D*VCq5uIJgO0f@fc#2onm8ZB+aQmVZX zZ;JN*C`AKAz6Qkhszf#i{^Djnf$wKekLbNRbvINe{?^c@21>~M(DvNQL_nJ(G5uU< zu%VXU;-7Jbyo{M@9uaL1sp)qmOSRmEc300%R_>eu3@ntP?q&RK=7=rsu*z~&jbUTA z(Aj+6EMyYsq%o{WYEHHI=FyTRuRjKLGBZvIG3VQ@!!Np`#FbOEkijP)0Np~}K!tfT zEAq4u3?R9%O*VNul6nJ`8DGLF$l4Z{oM?xf&DukUS&M<7SyCyGpmAevI6QRwSxyz_ zBCV3Snp;w62{?nQhn5R1rG1bTp73A@vUfyhr2Vq{^L;Uz#||j*YTV@FLPvl%Dv<0N z)Sf7@4!>+9YOGsW#6LNtE&D=FSYOHP9|B-DA4gEC&r~^WwTm1WzHB#pJz*dVFoae{ zP;`M*=Umvj)+3Sor^NcR5va9O5LjRGyPp?EfgNZzUiDvs$pY`IrxZHPec8d+wgr9` z72oU;vl2Qamd_9_Gcbv#rY;#}8k;|{{aw_wAP7ee=BV>SX=;U=deqb*4XtZi;m+c! zg%ed6`(x;bws`HY^VPUhg1@FXjw+A4IG&q6#5DR8fn$U8oeaPIQU}$z_1R{UgfyPJ zf+biqxKgoAn^P>j2@`rt$#}K95#w776>z~TQV_qSt2?Rb9OCM|G@p0ry?THxX5xbv zhgaYU!r)u9)tvx^tfT)*SGcd3z5i5Piz(KwH}N~a3OubDB(+!y*-gcoMYbj*-;q^W z>4zg51G6O~h9f(qC|K0SUG!pr4uww^&Y;Rj(}wybll{b{PmJnnhay(L)%QQy?kz{{ z=Ah+vjqAq4pZmJ2iJv^9%cH`E1HxCeLz;J}3AOFe=kRqQf44Igieo95B1~p_RlEOX zpVSRQz4q?83T$gA#G%UZ<2e4}1mMD{SG6&HRGs27h}boMQekaO;npHqd+v3Mb93Gf zEhQ!qbdah>5}N)g@~Nje!dXm-B?C-ys8XCdPDkiuNgQ5{7sa+G@_MS48+-=vAH{px zS9p0V7#*zk$Q%l4H-H{X?651E?n@Pq za6%G|e^L4&moS|pP{rg1Er1|U%3gysDr9yQu~I$5%GM;QXU(NE2?v?kvx#x(V<14< zvq}9`vSKc=`0i$*PSQspE}Hfq6I=_Q%0bCUe;0we;}#i-MaF6ubXH^v!HLh_izO@L zgf5Z%D_q1O?9>)qs`n4JxeuWL3upoEQ@f_@*vzl1XWc0FK!u#skbV<0v$WDL=}g|q zpHm@=jg!&A!;G3QZkpJGkaF+EYc`9T!I_fL;O>(#&YcpSd@?t_jgxWj3dM~2=|6QA zGqpvPai02QPQXl)l)*Spb8&Q)^At|UgiF3LV2)zXn46$y*{75c*MPci`}aj_C-0H! zUe*7mO1`hNl0qS`zIu-bc^cOiwcy#Gn}zl+{zd9+y5F!CB_UR3my>F28Cfmj24^9_ z3NPY+Y>#f2TGBx~)Mqov$!1<^DLw+Dx*QTRQ$=z1jA$;x;Wyal)aFxu%8$T9kZAL% zr8w4hv2k0Cn3R&{s>o-(!FN(swZFzmq(ysx*7gZ%`UeNo>kCayxP9#|BVrh_;F@21 zg44hgBXi5@c|tDkO>_e549pf@MLSc>VZDov_XeR)hcZHb>va?o^J4u&k60$=Pw;O5 z(eHDSp!Z{NxA&t}$1hCv`bq9*+db;6%qZG7If07-LcjYVmf`iMpoA zGj)|g-ZzU=j)irW_kEew_qnQv_!~9$H!5dZV61m?)35E0LFZ0;&ke@BzE3|BmfA!k I3UHAB3-{zpe*gdg literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift index 5c7b6a78e9..c74d28f32b 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateNavigationButtons.swift @@ -46,12 +46,12 @@ func leftNavigationButtonForChatInterfaceState(_ presentationInterfaceState: Cha } if canClear { - let buttonItem = UIBarButtonItem(title: "___clear", style: .plain, target: target, action: selector) + let buttonItem = UIBarButtonItem(title: strings.Conversation_ClearAll, style: .plain, target: target, action: selector) buttonItem.accessibilityLabel = title return ChatNavigationButton(action: .clearHistory, buttonItem: buttonItem) } else { title = strings.Conversation_ClearCache - let buttonItem = UIBarButtonItem(title: "___clear", style: .plain, target: target, action: selector) + let buttonItem = UIBarButtonItem(title: strings.Conversation_ClearCache, style: .plain, target: target, action: selector) buttonItem.accessibilityLabel = title return ChatNavigationButton(action: .clearCache, buttonItem: buttonItem) } diff --git a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift index 61dde36ecb..75d29e5a3b 100644 --- a/submodules/TextFormat/Sources/ChatTextInputAttributes.swift +++ b/submodules/TextFormat/Sources/ChatTextInputAttributes.swift @@ -604,20 +604,9 @@ private func refreshTextMentions(text: NSString, initialAttributedText: NSAttrib } } -private let textUrlEdgeCharacters: CharacterSet = { - var set: CharacterSet = .alphanumerics - set.formUnion(.symbols) - set.formUnion(.punctuationCharacters) - set.remove("(") - set.remove(")") - return set -}() - -private let textUrlCharacters: CharacterSet = { - var set: CharacterSet = textUrlEdgeCharacters - set.formUnion(.whitespacesAndNewlines) - return set -}() +private func isTextUrlInnerCharacter(_ c: UnicodeScalar) -> Bool { + return alphanumericCharacters.contains(c) || c == " " as UnicodeScalar +} private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributedString, attributedText: NSMutableAttributedString, fullRange: NSRange) { var textUrlRanges: [(NSRange, ChatTextInputTextUrlAttribute)] = [] @@ -635,7 +624,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed var validLower = range.lowerBound inner1: for i in range.lowerBound ..< range.upperBound { if let c = UnicodeScalar(text.character(at: i)) { - if textUrlCharacters.contains(c) { + if isTextUrlInnerCharacter(c) { validLower = i break inner1 } @@ -646,7 +635,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed var validUpper = range.upperBound inner2: for i in (validLower ..< range.upperBound).reversed() { if let c = UnicodeScalar(text.character(at: i)) { - if textUrlCharacters.contains(c) { + if isTextUrlInnerCharacter(c) { validUpper = i + 1 break inner2 } @@ -658,7 +647,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed let minLower = (i == 0) ? fullRange.lowerBound : textUrlRanges[i - 1].0.upperBound inner3: for i in (minLower ..< validLower).reversed() { if let c = UnicodeScalar(text.character(at: i)) { - if textUrlEdgeCharacters.contains(c) { + if alphanumericCharacters.contains(c) { validLower = i } else { break inner3 @@ -671,7 +660,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed let maxUpper = (i == textUrlRanges.count - 1) ? fullRange.upperBound : textUrlRanges[i + 1].0.lowerBound inner3: for i in validUpper ..< maxUpper { if let c = UnicodeScalar(text.character(at: i)) { - if textUrlEdgeCharacters.contains(c) { + if alphanumericCharacters.contains(c) { validUpper = i + 1 } else { break inner3 @@ -693,7 +682,7 @@ private func refreshTextUrls(text: NSString, initialAttributedText: NSAttributed var combine = true inner: for j in textUrlRanges[i].0.upperBound ..< textUrlRanges[i + 1].0.lowerBound { if let c = UnicodeScalar(text.character(at: j)) { - if textUrlCharacters.contains(c) { + if isTextUrlInnerCharacter(c) { } else { combine = false break inner From d212968040287ace8056dac4d168ed1acb4821b1 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 28 Apr 2026 20:35:06 +0200 Subject: [PATCH 11/69] Update API --- .../Telegram-iOS/en.lproj/Localizable.strings | 15 ++++++++ .../AccountContext/Sources/Premium.swift | 2 +- .../AuthorizationSequenceController.swift | 8 ++--- .../AuthorizationSequencePaymentScreen.swift | 34 ++++++++++++++++--- .../Sources/InAppPurchaseManager.swift | 17 ++++++---- .../Sources/PremiumIntroScreen.swift | 17 +++++++--- submodules/TelegramApi/Sources/Api0.swift | 4 +-- submodules/TelegramApi/Sources/Api13.swift | 26 ++++++++------ submodules/TelegramApi/Sources/Api31.swift | 26 ++++++++------ submodules/TelegramApi/Sources/Api40.swift | 15 ++++++++ .../Sources/Account/Account.swift | 6 ++-- .../TelegramCore/Sources/Authorization.swift | 16 ++++----- .../SyncCore_UnauthorizedAccountState.swift | 11 +++--- .../TelegramEngine/Payments/AppStore.swift | 6 ++-- .../Sources/SharedAccountContext.swift | 4 +-- 15 files changed, 143 insertions(+), 64 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index a2303f8781..41502d52e8 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16217,3 +16217,18 @@ Error: %8$@"; "Chat.AdminAction.ToastReactionsDeletedTextSingle" = "Reaction Deleted."; "Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted."; "Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted."; + +"Premium.SignUp.SignUpNewInfo" = "Get Telegram Premium for %@"; +"Premium.SignUp.SignUpNewInfo.Days_1" = "%@ day"; +"Premium.SignUp.SignUpNewInfo.Days_any" = "%@ days"; +"Premium.SignUp.SignUpNewInfoNone" = "Get Telegram Premium"; + +"Login.Fee.Support.NewText.Days_1" = "%@ day"; +"Login.Fee.Support.NewText.Days_any" = "%@ days"; +"Login.Fee.Support.NewText" = "Sign up for a %@ Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.Support.NewTextNone" = "Sign up for Telegram Premium subscription to help cover the SMS costs."; + +"Login.Fee.GetPremiumNone" = "Get Telegram Premium"; +"Login.Fee.GetPremiumForDays" = "Get Telegram Premium for %@"; +"Login.Fee.GetPremiumForDays.Days_1" = "%@ day"; +"Login.Fee.GetPremiumForDays.Days_any" = "%@ days"; diff --git a/submodules/AccountContext/Sources/Premium.swift b/submodules/AccountContext/Sources/Premium.swift index cfce7d1d14..359d11a4ba 100644 --- a/submodules/AccountContext/Sources/Premium.swift +++ b/submodules/AccountContext/Sources/Premium.swift @@ -45,7 +45,7 @@ public enum PremiumIntroSource { case todo case copyProtection case aiTools - case auth(String) + case auth(String, Int32) case premiumGift(TelegramMediaFile) } diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift index 313d21980a..1b4da5efdc 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift @@ -810,8 +810,8 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth return controller } - private func paymentController(number: String, phoneCodeHash: String, storeProduct: String, supportEmailAddress: String, supportEmailSubject: String) -> AuthorizationSequencePaymentScreen { - let controller = AuthorizationSequencePaymentScreen(sharedContext: self.sharedContext, engine: self.engine, presentationData: self.presentationData, inAppPurchaseManager: self.inAppPurchaseManager, phoneNumber: number, phoneCodeHash: phoneCodeHash, storeProduct: storeProduct, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject, back: { [weak self] in + private func paymentController(number: String, phoneCodeHash: String, storeProduct: String, premiumDays: Int32, supportEmailAddress: String, supportEmailSubject: String) -> AuthorizationSequencePaymentScreen { + let controller = AuthorizationSequencePaymentScreen(sharedContext: self.sharedContext, engine: self.engine, presentationData: self.presentationData, inAppPurchaseManager: self.inAppPurchaseManager, phoneNumber: number, phoneCodeHash: phoneCodeHash, storeProduct: storeProduct, premiumDays: premiumDays, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject, back: { [weak self] in guard let self else { return } @@ -1348,12 +1348,12 @@ public final class AuthorizationSequenceController: NavigationController, ASAuth } controllers.append(self.signUpController(firstName: firstName, lastName: lastName, termsOfService: termsOfService, displayCancel: displayCancel)) self.setViewControllers(controllers, animated: !self.viewControllers.isEmpty) - case let .payment(number, codeHash, storeProduct, supportEmailAddress, supportEmailSubject, _): + case let .payment(number, codeHash, storeProduct, premiumDays, supportEmailAddress, supportEmailSubject, _): var controllers: [ViewController] = [] if !self.otherAccountPhoneNumbers.1.isEmpty { controllers.append(self.splashController()) } - controllers.append(self.paymentController(number: number, phoneCodeHash: codeHash, storeProduct: storeProduct, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject)) + controllers.append(self.paymentController(number: number, phoneCodeHash: codeHash, storeProduct: storeProduct, premiumDays: premiumDays, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject)) self.setViewControllers(controllers, animated: !self.viewControllers.isEmpty) } } diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift index 07fe4c61f9..8658893b75 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift @@ -39,6 +39,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { let phoneNumber: String let phoneCodeHash: String let storeProduct: String + let premiumDays: Int32 let supportEmailAddress: String let supportEmailSubject: String @@ -50,6 +51,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { phoneNumber: String, phoneCodeHash: String, storeProduct: String, + premiumDays: Int32, supportEmailAddress: String, supportEmailSubject: String ) { @@ -60,6 +62,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { self.phoneNumber = phoneNumber self.phoneCodeHash = phoneCodeHash self.storeProduct = storeProduct + self.premiumDays = premiumDays self.supportEmailAddress = supportEmailAddress self.supportEmailSubject = supportEmailSubject } @@ -115,7 +118,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { self.state?.updated() let (currency, amount) = storeProduct.priceCurrencyAndAmount - let purpose: AppStoreTransactionPurpose = .authCode(restore: false, phoneNumber: component.phoneNumber, phoneCodeHash: component.phoneCodeHash, currency: currency, amount: amount) + let purpose: AppStoreTransactionPurpose = .authCode(restore: false, phoneNumber: component.phoneNumber, phoneCodeHash: component.phoneCodeHash, premiumDays: component.premiumDays, currency: currency, amount: amount) let _ = (component.engine.payments.canPurchasePremium(purpose: purpose) |> deliverOnMainQueue).start(next: { [weak self] available in guard let self else { @@ -333,13 +336,24 @@ final class AuthorizationSequencePaymentScreenComponent: Component { )) ) ) + + let supportText: String + if component.premiumDays == 7 { + supportText = environment.strings.Login_Fee_Support_Text + } else if component.premiumDays > 0 { + let daysString = environment.strings.Login_Fee_Support_NewText_Days(component.premiumDays) + supportText = environment.strings.Login_Fee_Support_NewText(daysString).string + } else { + supportText = environment.strings.Login_Fee_Support_NewTextNone + } + items.append( AnyComponentWithIdentity( id: "support", component: AnyComponent(ParagraphComponent( title: environment.strings.Login_Fee_Support_Title, titleColor: textColor, - text: environment.strings.Login_Fee_Support_Text, + text: supportText, textColor: secondaryTextColor, iconName: "Premium/Authorization/Support", iconColor: linkColor, @@ -351,7 +365,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { sharedContext: component.sharedContext, engine: component.engine, inAppPurchaseManager: component.inAppPurchaseManager, - source: .auth(product.price), + source: .auth(product.price, component.premiumDays), proceed: { [weak self] in self?.proceed() } @@ -410,6 +424,16 @@ final class AuthorizationSequencePaymentScreenComponent: Component { } let buttonString = environment.strings.Login_Fee_SignUp(priceString).string + let buttonSubtitle: String + if component.premiumDays == 7 { + buttonSubtitle = environment.strings.Login_Fee_GetPremiumForAWeek + } else if component.premiumDays > 0 { + let daysString = environment.strings.Login_Fee_GetPremiumForDays_Days(component.premiumDays) + buttonSubtitle = environment.strings.Login_Fee_GetPremiumForDays(daysString).string + } else { + buttonSubtitle = environment.strings.Login_Fee_GetPremiumNone + } + let buttonAttributedString = NSMutableAttributedString(string: buttonString, font: Font.semibold(17.0), textColor: environment.theme.list.itemCheckColors.foregroundColor, paragraphAlignment: .center) let buttonSize = self.button.update( transition: transition, @@ -425,7 +449,7 @@ final class AuthorizationSequencePaymentScreenComponent: Component { component: AnyComponent( VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent(text: .plain(buttonAttributedString)))), - AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: environment.strings.Login_Fee_GetPremiumForAWeek, font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center))))) + AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent(text: .plain(NSAttributedString(string: buttonSubtitle, font: Font.medium(11.0), textColor: environment.theme.list.itemCheckColors.foregroundColor.withAlphaComponent(0.7), paragraphAlignment: .center))))) ], spacing: 1.0) ) ), @@ -467,6 +491,7 @@ public final class AuthorizationSequencePaymentScreen: ViewControllerComponentCo phoneNumber: String, phoneCodeHash: String, storeProduct: String, + premiumDays: Int32, supportEmailAddress: String, supportEmailSubject: String, back: @escaping () -> Void @@ -479,6 +504,7 @@ public final class AuthorizationSequencePaymentScreen: ViewControllerComponentCo phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, storeProduct: storeProduct, + premiumDays: premiumDays, supportEmailAddress: supportEmailAddress, supportEmailSubject: supportEmailSubject ), navigationBarAppearance: .transparent, theme: .default, updatedPresentationData: (initial: presentationData, signal: .single(presentationData))) diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 8929b51249..74f3824243 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -662,6 +662,7 @@ private final class PendingInAppPurchaseState: Codable { case restore case phoneNumber case phoneCodeHash + case premiumDays } enum PurposeType: Int32 { @@ -686,7 +687,7 @@ private final class PendingInAppPurchaseState: Codable { case stars(count: Int64, peerId: EnginePeer.Id?) case starsGift(peerId: EnginePeer.Id, count: Int64) case starsGiveaway(stars: Int64, boostPeer: EnginePeer.Id, additionalPeerIds: [EnginePeer.Id], countries: [String], onlyNewSubscribers: Bool, showWinners: Bool, prizeDescription: String?, randomId: Int64, untilDate: Int32, users: Int32) - case authCode(restore: Bool, phoneNumber: String, phoneCodeHash: String) + case authCode(restore: Bool, phoneNumber: String, phoneCodeHash: String, premiumDays: Int32) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -748,7 +749,8 @@ private final class PendingInAppPurchaseState: Codable { self = .authCode( restore: try container.decode(Bool.self, forKey: .restore), phoneNumber: try container.decode(String.self, forKey: .phoneNumber), - phoneCodeHash: try container.decode(String.self, forKey: .phoneCodeHash) + phoneCodeHash: try container.decode(String.self, forKey: .phoneCodeHash), + premiumDays: try container.decode(Int32.self, forKey: .premiumDays), ) default: throw DecodingError.generic @@ -804,11 +806,12 @@ private final class PendingInAppPurchaseState: Codable { try container.encode(randomId, forKey: .randomId) try container.encode(untilDate, forKey: .untilDate) try container.encode(users, forKey: .users) - case let .authCode(restore, phoneNumber, phoneCodeHash): + case let .authCode(restore, phoneNumber, phoneCodeHash, premiumDays): try container.encode(PurposeType.authCode.rawValue, forKey: .type) try container.encode(restore, forKey: .restore) try container.encode(phoneNumber, forKey: .phoneNumber) try container.encode(phoneCodeHash, forKey: .phoneCodeHash) + try container.encode(premiumDays, forKey: .premiumDays) } } @@ -832,8 +835,8 @@ private final class PendingInAppPurchaseState: Codable { self = .starsGift(peerId: peerId, count: count) case let .starsGiveaway(stars, boostPeer, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, _, _, users): self = .starsGiveaway(stars: stars, boostPeer: boostPeer, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, users: users) - case let .authCode(restore, phoneNumber, phoneCodeHash, _, _): - self = .authCode(restore: restore, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash) + case let .authCode(restore, phoneNumber, phoneCodeHash, premiumDays, _, _): + self = .authCode(restore: restore, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, premiumDays: premiumDays) } } @@ -858,8 +861,8 @@ private final class PendingInAppPurchaseState: Codable { return .starsGift(peerId: peerId, count: count, currency: currency, amount: amount) case let .starsGiveaway(stars, boostPeer, additionalPeerIds, countries, onlyNewSubscribers, showWinners, prizeDescription, randomId, untilDate, users): return .starsGiveaway(stars: stars, boostPeer: boostPeer, additionalPeerIds: additionalPeerIds, countries: countries, onlyNewSubscribers: onlyNewSubscribers, showWinners: showWinners, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: currency, amount: amount, users: users) - case let .authCode(restore, phoneNumber, phoneCodeHash): - return .authCode(restore: restore, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, currency: currency, amount: amount) + case let .authCode(restore, phoneNumber, phoneCodeHash, premiumDays): + return .authCode(restore: restore, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, premiumDays: premiumDays, currency: currency, amount: amount) } } } diff --git a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift index 7eabf03dfd..d4bef601c4 100644 --- a/submodules/PremiumUI/Sources/PremiumIntroScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumIntroScreen.swift @@ -329,8 +329,8 @@ public enum PremiumSource: Equatable { } else { return false } - case let .auth(lhsPrice): - if case let .auth(rhsPrice) = rhs, lhsPrice == rhsPrice { + case let .auth(lhsPrice, lhsDays): + if case let .auth(rhsPrice, rhsDays) = rhs, lhsPrice == rhsPrice, lhsDays == rhsDays { return true } else { return false @@ -391,7 +391,7 @@ public enum PremiumSource: Equatable { case todo case copyProtection case aiTools - case auth(String) + case auth(String, Int32) case premiumGift(TelegramMediaFile) var identifier: String? { @@ -3786,9 +3786,16 @@ private final class PremiumIntroScreenComponent: CombinedComponent { if !buttonIsHidden { let buttonTitle: String var buttonSubtitle: String? - if case let .auth(price) = context.component.source { + if case let .auth(price, days) = context.component.source { buttonTitle = environment.strings.Premium_Week_SignUp(price).string - buttonSubtitle = environment.strings.Premium_Week_SignUpInfo + if days == 7 { + buttonSubtitle = environment.strings.Premium_Week_SignUpInfo + } else if days > 0 { + let daysString = environment.strings.Premium_SignUp_SignUpNewInfo_Days(days) + buttonSubtitle = environment.strings.Premium_SignUp_SignUpNewInfo(daysString).string + } else { + buttonSubtitle = environment.strings.Premium_SignUp_SignUpNewInfoNone + } } else if isUnusedGift { buttonTitle = environment.strings.Premium_Gift_ApplyLink } else if state.isPremium == true && state.canUpgrade { diff --git a/submodules/TelegramApi/Sources/Api0.swift b/submodules/TelegramApi/Sources/Api0.swift index b097a2a091..fd0a5e29ee 100644 --- a/submodules/TelegramApi/Sources/Api0.swift +++ b/submodules/TelegramApi/Sources/Api0.swift @@ -518,7 +518,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[853188252] = { return Api.InputStickerSetItem.parse_inputStickerSetItem($0) } dict[70813275] = { return Api.InputStickeredMedia.parse_inputStickeredMediaDocument($0) } dict[1251549527] = { return Api.InputStickeredMedia.parse_inputStickeredMediaPhoto($0) } - dict[-1682807955] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentAuthCode($0) } + dict[1069645911] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentAuthCode($0) } dict[1634697192] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentGiftPremium($0) } dict[-75955309] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentPremiumGiftCode($0) } dict[369444042] = { return Api.InputStorePaymentPurpose.parse_inputStorePaymentPremiumGiveaway($0) } @@ -1358,7 +1358,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { dict[-503089271] = { return Api.auth.PasskeyLoginOptions.parse_passkeyLoginOptions($0) } dict[326715557] = { return Api.auth.PasswordRecovery.parse_passwordRecovery($0) } dict[1577067778] = { return Api.auth.SentCode.parse_sentCode($0) } - dict[-527082948] = { return Api.auth.SentCode.parse_sentCodePaymentRequired($0) } + dict[-125665601] = { return Api.auth.SentCode.parse_sentCodePaymentRequired($0) } dict[596704836] = { return Api.auth.SentCode.parse_sentCodeSuccess($0) } dict[1035688326] = { return Api.auth.SentCodeType.parse_sentCodeTypeApp($0) } dict[1398007207] = { return Api.auth.SentCodeType.parse_sentCodeTypeCall($0) } diff --git a/submodules/TelegramApi/Sources/Api13.swift b/submodules/TelegramApi/Sources/Api13.swift index 1a0cc1b88c..cd4f464e19 100644 --- a/submodules/TelegramApi/Sources/Api13.swift +++ b/submodules/TelegramApi/Sources/Api13.swift @@ -1209,17 +1209,19 @@ public extension Api { public var flags: Int32 public var phoneNumber: String public var phoneCodeHash: String + public var premiumDays: Int32 public var currency: String public var amount: Int64 - public init(flags: Int32, phoneNumber: String, phoneCodeHash: String, currency: String, amount: Int64) { + public init(flags: Int32, phoneNumber: String, phoneCodeHash: String, premiumDays: Int32, currency: String, amount: Int64) { self.flags = flags self.phoneNumber = phoneNumber self.phoneCodeHash = phoneCodeHash + self.premiumDays = premiumDays self.currency = currency self.amount = amount } public func descriptionFields() -> (String, [(String, ConstructorParameterDescription)]) { - return ("inputStorePaymentAuthCode", [("flags", ConstructorParameterDescription(self.flags)), ("phoneNumber", ConstructorParameterDescription(self.phoneNumber)), ("phoneCodeHash", ConstructorParameterDescription(self.phoneCodeHash)), ("currency", ConstructorParameterDescription(self.currency)), ("amount", ConstructorParameterDescription(self.amount))]) + return ("inputStorePaymentAuthCode", [("flags", ConstructorParameterDescription(self.flags)), ("phoneNumber", ConstructorParameterDescription(self.phoneNumber)), ("phoneCodeHash", ConstructorParameterDescription(self.phoneCodeHash)), ("premiumDays", ConstructorParameterDescription(self.premiumDays)), ("currency", ConstructorParameterDescription(self.currency)), ("amount", ConstructorParameterDescription(self.amount))]) } } public class Cons_inputStorePaymentGiftPremium: TypeConstructorDescription { @@ -1362,11 +1364,12 @@ public extension Api { switch self { case .inputStorePaymentAuthCode(let _data): if boxed { - buffer.appendInt32(-1682807955) + buffer.appendInt32(1069645911) } serializeInt32(_data.flags, buffer: buffer, boxed: false) serializeString(_data.phoneNumber, buffer: buffer, boxed: false) serializeString(_data.phoneCodeHash, buffer: buffer, boxed: false) + serializeInt32(_data.premiumDays, buffer: buffer, boxed: false) serializeString(_data.currency, buffer: buffer, boxed: false) serializeInt64(_data.amount, buffer: buffer, boxed: false) break @@ -1488,7 +1491,7 @@ public extension Api { public func descriptionFields() -> (String, [(String, ConstructorParameterDescription)]) { switch self { case .inputStorePaymentAuthCode(let _data): - return ("inputStorePaymentAuthCode", [("flags", ConstructorParameterDescription(_data.flags)), ("phoneNumber", ConstructorParameterDescription(_data.phoneNumber)), ("phoneCodeHash", ConstructorParameterDescription(_data.phoneCodeHash)), ("currency", ConstructorParameterDescription(_data.currency)), ("amount", ConstructorParameterDescription(_data.amount))]) + return ("inputStorePaymentAuthCode", [("flags", ConstructorParameterDescription(_data.flags)), ("phoneNumber", ConstructorParameterDescription(_data.phoneNumber)), ("phoneCodeHash", ConstructorParameterDescription(_data.phoneCodeHash)), ("premiumDays", ConstructorParameterDescription(_data.premiumDays)), ("currency", ConstructorParameterDescription(_data.currency)), ("amount", ConstructorParameterDescription(_data.amount))]) case .inputStorePaymentGiftPremium(let _data): return ("inputStorePaymentGiftPremium", [("userId", ConstructorParameterDescription(_data.userId)), ("currency", ConstructorParameterDescription(_data.currency)), ("amount", ConstructorParameterDescription(_data.amount))]) case .inputStorePaymentPremiumGiftCode(let _data): @@ -1513,17 +1516,20 @@ public extension Api { _2 = parseString(reader) var _3: String? _3 = parseString(reader) - var _4: String? - _4 = parseString(reader) - var _5: Int64? - _5 = reader.readInt64() + var _4: Int32? + _4 = reader.readInt32() + var _5: String? + _5 = parseString(reader) + var _6: Int64? + _6 = reader.readInt64() let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil let _c4 = _4 != nil let _c5 = _5 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 { - return Api.InputStorePaymentPurpose.inputStorePaymentAuthCode(Cons_inputStorePaymentAuthCode(flags: _1!, phoneNumber: _2!, phoneCodeHash: _3!, currency: _4!, amount: _5!)) + let _c6 = _6 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 { + return Api.InputStorePaymentPurpose.inputStorePaymentAuthCode(Cons_inputStorePaymentAuthCode(flags: _1!, phoneNumber: _2!, phoneCodeHash: _3!, premiumDays: _4!, currency: _5!, amount: _6!)) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api31.swift b/submodules/TelegramApi/Sources/Api31.swift index ddecedb633..4e02876037 100644 --- a/submodules/TelegramApi/Sources/Api31.swift +++ b/submodules/TelegramApi/Sources/Api31.swift @@ -1227,18 +1227,20 @@ public extension Api.auth { public var phoneCodeHash: String public var supportEmailAddress: String public var supportEmailSubject: String + public var premiumDays: Int32 public var currency: String public var amount: Int64 - public init(storeProduct: String, phoneCodeHash: String, supportEmailAddress: String, supportEmailSubject: String, currency: String, amount: Int64) { + public init(storeProduct: String, phoneCodeHash: String, supportEmailAddress: String, supportEmailSubject: String, premiumDays: Int32, currency: String, amount: Int64) { self.storeProduct = storeProduct self.phoneCodeHash = phoneCodeHash self.supportEmailAddress = supportEmailAddress self.supportEmailSubject = supportEmailSubject + self.premiumDays = premiumDays self.currency = currency self.amount = amount } public func descriptionFields() -> (String, [(String, ConstructorParameterDescription)]) { - return ("sentCodePaymentRequired", [("storeProduct", ConstructorParameterDescription(self.storeProduct)), ("phoneCodeHash", ConstructorParameterDescription(self.phoneCodeHash)), ("supportEmailAddress", ConstructorParameterDescription(self.supportEmailAddress)), ("supportEmailSubject", ConstructorParameterDescription(self.supportEmailSubject)), ("currency", ConstructorParameterDescription(self.currency)), ("amount", ConstructorParameterDescription(self.amount))]) + return ("sentCodePaymentRequired", [("storeProduct", ConstructorParameterDescription(self.storeProduct)), ("phoneCodeHash", ConstructorParameterDescription(self.phoneCodeHash)), ("supportEmailAddress", ConstructorParameterDescription(self.supportEmailAddress)), ("supportEmailSubject", ConstructorParameterDescription(self.supportEmailSubject)), ("premiumDays", ConstructorParameterDescription(self.premiumDays)), ("currency", ConstructorParameterDescription(self.currency)), ("amount", ConstructorParameterDescription(self.amount))]) } } public class Cons_sentCodeSuccess: TypeConstructorDescription { @@ -1272,12 +1274,13 @@ public extension Api.auth { break case .sentCodePaymentRequired(let _data): if boxed { - buffer.appendInt32(-527082948) + buffer.appendInt32(-125665601) } serializeString(_data.storeProduct, buffer: buffer, boxed: false) serializeString(_data.phoneCodeHash, buffer: buffer, boxed: false) serializeString(_data.supportEmailAddress, buffer: buffer, boxed: false) serializeString(_data.supportEmailSubject, buffer: buffer, boxed: false) + serializeInt32(_data.premiumDays, buffer: buffer, boxed: false) serializeString(_data.currency, buffer: buffer, boxed: false) serializeInt64(_data.amount, buffer: buffer, boxed: false) break @@ -1295,7 +1298,7 @@ public extension Api.auth { case .sentCode(let _data): return ("sentCode", [("flags", ConstructorParameterDescription(_data.flags)), ("type", ConstructorParameterDescription(_data.type)), ("phoneCodeHash", ConstructorParameterDescription(_data.phoneCodeHash)), ("nextType", ConstructorParameterDescription(_data.nextType)), ("timeout", ConstructorParameterDescription(_data.timeout))]) case .sentCodePaymentRequired(let _data): - return ("sentCodePaymentRequired", [("storeProduct", ConstructorParameterDescription(_data.storeProduct)), ("phoneCodeHash", ConstructorParameterDescription(_data.phoneCodeHash)), ("supportEmailAddress", ConstructorParameterDescription(_data.supportEmailAddress)), ("supportEmailSubject", ConstructorParameterDescription(_data.supportEmailSubject)), ("currency", ConstructorParameterDescription(_data.currency)), ("amount", ConstructorParameterDescription(_data.amount))]) + return ("sentCodePaymentRequired", [("storeProduct", ConstructorParameterDescription(_data.storeProduct)), ("phoneCodeHash", ConstructorParameterDescription(_data.phoneCodeHash)), ("supportEmailAddress", ConstructorParameterDescription(_data.supportEmailAddress)), ("supportEmailSubject", ConstructorParameterDescription(_data.supportEmailSubject)), ("premiumDays", ConstructorParameterDescription(_data.premiumDays)), ("currency", ConstructorParameterDescription(_data.currency)), ("amount", ConstructorParameterDescription(_data.amount))]) case .sentCodeSuccess(let _data): return ("sentCodeSuccess", [("authorization", ConstructorParameterDescription(_data.authorization))]) } @@ -1341,18 +1344,21 @@ public extension Api.auth { _3 = parseString(reader) var _4: String? _4 = parseString(reader) - var _5: String? - _5 = parseString(reader) - var _6: Int64? - _6 = reader.readInt64() + var _5: Int32? + _5 = reader.readInt32() + var _6: String? + _6 = parseString(reader) + var _7: Int64? + _7 = reader.readInt64() let _c1 = _1 != nil let _c2 = _2 != nil let _c3 = _3 != nil let _c4 = _4 != nil let _c5 = _5 != nil let _c6 = _6 != nil - if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 { - return Api.auth.SentCode.sentCodePaymentRequired(Cons_sentCodePaymentRequired(storeProduct: _1!, phoneCodeHash: _2!, supportEmailAddress: _3!, supportEmailSubject: _4!, currency: _5!, amount: _6!)) + let _c7 = _7 != nil + if _c1 && _c2 && _c3 && _c4 && _c5 && _c6 && _c7 { + return Api.auth.SentCode.sentCodePaymentRequired(Cons_sentCodePaymentRequired(storeProduct: _1!, phoneCodeHash: _2!, supportEmailAddress: _3!, supportEmailSubject: _4!, premiumDays: _5!, currency: _6!, amount: _7!)) } else { return nil diff --git a/submodules/TelegramApi/Sources/Api40.swift b/submodules/TelegramApi/Sources/Api40.swift index a872989b8d..27a36a3d77 100644 --- a/submodules/TelegramApi/Sources/Api40.swift +++ b/submodules/TelegramApi/Sources/Api40.swift @@ -103,6 +103,21 @@ public extension Api.functions.account { }) } } +public extension Api.functions.account { + static func confirmBotConnection(botId: Api.InputUser) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + let buffer = Buffer() + buffer.appendInt32(1743593320) + botId.serialize(buffer, true) + return (FunctionDescription(name: "account.confirmBotConnection", parameters: [("botId", ConstructorParameterDescription(botId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.Bool? in + let reader = BufferReader(buffer) + var result: Api.Bool? + if let signature = reader.readInt32() { + result = Api.parse(reader, signature: signature) as? Api.Bool + } + return result + }) + } +} public extension Api.functions.account { static func confirmPasswordEmail(code: String) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() diff --git a/submodules/TelegramCore/Sources/Account/Account.swift b/submodules/TelegramCore/Sources/Account/Account.swift index ee663bb5aa..232b84e1e8 100644 --- a/submodules/TelegramCore/Sources/Account/Account.swift +++ b/submodules/TelegramCore/Sources/Account/Account.swift @@ -128,7 +128,7 @@ public class UnauthorizedAccount { if let nextType = nextType { parsedNextType = AuthorizationCodeNextType(apiType: nextType) } - if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(phoneNumber, _, _, _, _, syncContacts) = state.contents { + if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(phoneNumber, _, _, _, _, _, syncContacts) = state.contents { transaction.setState(UnauthorizedAccountState(isTestingEnvironment: testingEnvironment, masterDatacenterId: masterDatacenterId, contents: .confirmationCodeEntry(number: phoneNumber, type: SentAuthorizationCodeType(apiType: type), hash: phoneCodeHash, timeout: codeTimeout, nextType: parsedNextType, syncContacts: syncContacts, previousCodeEntry: nil, usePrevious: false))) } }).start() @@ -139,7 +139,7 @@ public class UnauthorizedAccount { let (futureAuthToken, apiUser) = (authorizationData.futureAuthToken, authorizationData.user) let _ = postbox.transaction({ [weak self] transaction in var syncContacts = true - if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(_, _, _, _, _, syncContactsValue) = state.contents { + if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(_, _, _, _, _, _, syncContactsValue) = state.contents { syncContacts = syncContactsValue } @@ -166,7 +166,7 @@ public class UnauthorizedAccount { let termsOfService = authorizationSignUpRequiredData.termsOfService let _ = postbox.transaction({ [weak self] transaction in if let self { - if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(number, codeHash, _, _, _, syncContacts) = state.contents { + if let state = transaction.getState() as? UnauthorizedAccountState, case let .payment(number, codeHash, _, _, _, _, syncContacts) = state.contents { let _ = beginSignUp( account: self, data: AuthorizationSignUpData( diff --git a/submodules/TelegramCore/Sources/Authorization.swift b/submodules/TelegramCore/Sources/Authorization.swift index 25caa0afc8..5bc16112de 100644 --- a/submodules/TelegramCore/Sources/Authorization.swift +++ b/submodules/TelegramCore/Sources/Authorization.swift @@ -527,8 +527,8 @@ private func internalResendAuthorizationCode(accountManager: AccountManager Signal { @@ -155,12 +155,12 @@ private func apiInputStorePaymentPurpose(postbox: Postbox, purpose: AppStoreTran return .single(.inputStorePaymentStarsGiveaway(.init(flags: flags, stars: stars, boostPeer: apiBoostPeer, additionalPeers: additionalPeers, countriesIso2: countries, prizeDescription: prizeDescription, randomId: randomId, untilDate: untilDate, currency: currency, amount: amount, users: users))) } |> switchToLatest - case let .authCode(restore, phoneNumber, phoneCodeHash, currency, amount): + case let .authCode(restore, phoneNumber, phoneCodeHash, premiumDays, currency, amount): var flags: Int32 = 0 if restore { flags |= (1 << 0) } - return .single(.inputStorePaymentAuthCode(.init(flags: flags, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, currency: currency, amount: amount))) + return .single(.inputStorePaymentAuthCode(.init(flags: flags, phoneNumber: phoneNumber, phoneCodeHash: phoneCodeHash, premiumDays: premiumDays, currency: currency, amount: amount))) } } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 805fade423..2e9920d1fa 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2975,8 +2975,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { mappedSource = .copyProtection case .aiTools: mappedSource = .aiTools - case let .auth(price): - mappedSource = .auth(price) + case let .auth(price, days): + mappedSource = .auth(price, days) case let .premiumGift(file): mappedSource = .premiumGift(file) } From 92c350b5590feae2e145d344a720dd0809a96556 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 28 Apr 2026 21:18:02 +0200 Subject: [PATCH 12/69] Fix external text sharing --- .../Chat/ChatControllerLoadDisplayNode.swift | 5 ++++ .../TelegramUI/Sources/ChatController.swift | 3 +++ .../Sources/NavigateToChatController.swift | 24 +++++++++---------- .../TelegramUI/Sources/OpenResolvedUrl.swift | 22 +++++------------ 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index b36b81a3ca..1934f56d65 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -530,6 +530,8 @@ extension ChatControllerImpl { let initialInterfaceState = contentData.initialInterfaceState contentData.initialInterfaceState = nil + let initialTextInputState = self.initialTextInputState + self.initialTextInputState = nil if !self.didInitializePersistentPeerInterfaceData, let initialPersistentPeerData = contentData.initialPersistentPeerData { self.didInitializePersistentPeerInterfaceData = true @@ -541,6 +543,9 @@ extension ChatControllerImpl { if let initialInterfaceState { interfaceState = initialInterfaceState.interfaceState } + if let initialTextInputState { + interfaceState = interfaceState.withUpdatedComposeInputState(initialTextInputState) + } if let channel = contentData.state.renderedPeer?.peer as? TelegramChannel { if channel.hasBannedPermission(.banSendVoice) != nil && channel.hasBannedPermission(.banSendInstantVideos) != nil { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index ac67d3c25e..521ea3d5c1 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -252,6 +252,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let context: AccountContext public internal(set) var chatLocation: ChatLocation public internal(set) var subject: ChatControllerSubject? + var initialTextInputState: ChatTextInputState? var botStart: ChatControllerInitialBotStart? var attachBotStart: ChatControllerInitialAttachBotStart? @@ -642,6 +643,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G chatListFilter: Int32? = nil, chatNavigationStack: [ChatNavigationStackItem] = [], customChatNavigationStack: [EnginePeer.Id]? = nil, + initialTextInputState: ChatTextInputState? = nil, params: ChatControllerParams? = nil ) { self.initTimestamp = CFAbsoluteTimeGetCurrent() @@ -654,6 +656,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.chatLocation = chatLocation self.chatLocationContextHolder = chatLocationContextHolder self.subject = subject + self.initialTextInputState = initialTextInputState self.botStart = botStart self.attachBotStart = attachBotStart self.botAppStart = botAppStart diff --git a/submodules/TelegramUI/Sources/NavigateToChatController.swift b/submodules/TelegramUI/Sources/NavigateToChatController.swift index 60f4344fa0..4f9e8b5f2b 100644 --- a/submodules/TelegramUI/Sources/NavigateToChatController.swift +++ b/submodules/TelegramUI/Sources/NavigateToChatController.swift @@ -253,8 +253,14 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam controller.presentBotApp(botApp: botAppStart.botApp, botPeer: peer, payload: botAppStart.payload, mode: botAppStart.mode) } } + + if controller.chatLocation.peerId == params.chatLocation.asChatLocation.peerId && controller.chatLocation.threadId == params.chatLocation.asChatLocation.threadId && (controller.subject != .scheduledMessages || controller.subject == params.subject) { + if let updateTextInputState = params.updateTextInputState { + controller.updateTextInputState(updateTextInputState) + } + } } else { - controller = ChatControllerImpl(context: params.context, chatLocation: params.chatLocation.asChatLocation, chatLocationContextHolder: params.chatLocationContextHolder, subject: params.subject, botStart: params.botStart, attachBotStart: params.attachBotStart, botAppStart: params.botAppStart, peekData: params.peekData, chatListFilter: params.chatListFilter, chatNavigationStack: params.chatNavigationStack, customChatNavigationStack: params.customChatNavigationStack) + controller = ChatControllerImpl(context: params.context, chatLocation: params.chatLocation.asChatLocation, chatLocationContextHolder: params.chatLocationContextHolder, subject: params.subject, botStart: params.botStart, attachBotStart: params.attachBotStart, botAppStart: params.botAppStart, peekData: params.peekData, chatListFilter: params.chatListFilter, chatNavigationStack: params.chatNavigationStack, customChatNavigationStack: params.customChatNavigationStack, initialTextInputState: params.updateTextInputState) if let botAppStart = params.botAppStart, case let .peer(peer) = params.chatLocation { Queue.mainQueue().after(0.1) { @@ -263,14 +269,6 @@ public func navigateToChatControllerImpl(_ params: NavigateToChatControllerParam } } - if controller.chatLocation.peerId == params.chatLocation.asChatLocation.peerId && controller.chatLocation.threadId == params.chatLocation.asChatLocation.threadId && (controller.subject != .scheduledMessages || controller.subject == params.subject) { - if let updateTextInputState = params.updateTextInputState { - Queue.mainQueue().after(0.1) { - controller.updateTextInputState(updateTextInputState) - } - } - } - controller.purposefulAction = params.purposefulAction if let search = params.activateMessageSearch { controller.activateSearch(domain: search.0, query: search.1) @@ -449,7 +447,7 @@ public func navigateToForumThreadImpl(context: AccountContext, peerId: EnginePee } } -public func chatControllerForForumThreadImpl(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64) -> Signal { +public func chatControllerForForumThreadImpl(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, initialTextInputState: ChatTextInputState? = nil) -> Signal { return context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) ) @@ -477,7 +475,8 @@ public func chatControllerForForumThreadImpl(context: AccountContext, peerId: En initialAnchor: .automatic, isNotAvailable: false )), - chatLocationContextHolder: Atomic(value: nil) + chatLocationContextHolder: Atomic(value: nil), + initialTextInputState: initialTextInputState )) } else { return fetchAndPreloadReplyThreadInfo(context: context, subject: .groupMessage(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: Int32(clamping: threadId))), atMessageId: nil, preload: false) @@ -489,7 +488,8 @@ public func chatControllerForForumThreadImpl(context: AccountContext, peerId: En return ChatControllerImpl( context: context, chatLocation: .replyThread(message: result.message), - chatLocationContextHolder: result.contextHolder + chatLocationContextHolder: result.contextHolder, + initialTextInputState: initialTextInputState ) } } diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index 5d78767dcb..d77a7eb022 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -20,7 +20,6 @@ import JoinLinkPreviewUI import LanguageLinkPreviewUI import SettingsUI import UrlHandling -import ChatInterfaceState import TelegramCallsUI import UndoUI import ImportStickerPackUI @@ -631,16 +630,16 @@ func openResolvedUrlImpl( } let chatController: Signal if let threadId { - chatController = chatControllerForForumThreadImpl(context: context, peerId: peerId, threadId: threadId) + chatController = chatControllerForForumThreadImpl(context: context, peerId: peerId, threadId: threadId, initialTextInputState: textInputState) } else { - chatController = .single(ChatControllerImpl(context: context, chatLocation: .peer(id: peerId))) + chatController = .single(ChatControllerImpl(context: context, chatLocation: .peer(id: peerId), initialTextInputState: textInputState)) } - + let _ = (chatController |> deliverOnMainQueue).start(next: { [weak navigationController] chatController in guard let navigationController else { return - } + } var controllers = navigationController.viewControllers.filter { controller in if controller is PeerSelectionController { return false @@ -651,17 +650,8 @@ func openResolvedUrlImpl( navigationController.setViewControllers(controllers, animated: true) }) } - - if let textInputState = textInputState { - let _ = (ChatInterfaceState.update(engine: context.engine, peerId: peerId, threadId: threadId, { currentState in - return currentState.withUpdatedComposeInputState(textInputState) - }) - |> deliverOnMainQueue).startStandalone(completed: { - updateControllers() - }) - } else { - updateControllers() - } + + updateControllers() } if let to = to { From fb4f11e54f102f28812068ef2b39bc6cab32be13 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 01:33:00 +0400 Subject: [PATCH 13/69] Roll back reply changes --- .../Sources/State/PendingMessageManager.swift | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift index 02d29d7a6b..94767eeedf 100644 --- a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift +++ b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift @@ -1038,22 +1038,10 @@ public final class PendingMessageManager { var flags: Int32 = 0 - var topMsgId: Int32? - var monoforumPeerId: Api.InputPeer? - if let threadId = messages[0].0.threadId { - if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { - if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { - monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) - } - } else { - topMsgId = Int32(clamping: threadId) - } - } - for attribute in messages[0].0.attributes { if let replyAttribute = attribute as? ReplyMessageAttribute { replyMessageId = replyAttribute.messageId.id - if peerId != replyAttribute.messageId.peerId || (topMsgId != nil && replyAttribute.threadMessageId != nil && replyAttribute.threadMessageId?.id != topMsgId) { + if peerId != replyAttribute.messageId.peerId { replyPeerId = replyAttribute.messageId.peerId } if replyAttribute.isQuote { @@ -1146,10 +1134,19 @@ public final class PendingMessageManager { } } - if topMsgId != nil { - flags |= Int32(1 << 9) + var topMsgId: Int32? + var monoforumPeerId: Api.InputPeer? + if let threadId = messages[0].0.threadId { + if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } + } else { + flags |= Int32(1 << 9) + topMsgId = Int32(clamping: threadId) + } } - + var quickReplyShortcut: Api.InputQuickReplyShortcut? if let quickReply { if let threadId = messages[0].0.threadId { @@ -1236,7 +1233,20 @@ public final class PendingMessageManager { if bubbleUpEmojiOrStickersets { flags |= Int32(1 << 15) } - + + var topMsgId: Int32? + var monoforumPeerId: Api.InputPeer? + if let threadId = messages[0].0.threadId { + if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } + } else { + flags |= Int32(1 << 9) + topMsgId = Int32(clamping: threadId) + } + } + var replyTo: Api.InputReplyTo? if let replyMessageId = replyMessageId { flags |= 1 << 0 @@ -1561,7 +1571,7 @@ public final class PendingMessageManager { for attribute in message.attributes { if let replyAttribute = attribute as? ReplyMessageAttribute { replyMessageId = replyAttribute.messageId.id - if peer.id != replyAttribute.messageId.peerId || (topMsgId != nil && replyAttribute.threadMessageId != nil && replyAttribute.threadMessageId?.id != topMsgId) { + if peer.id != replyAttribute.messageId.peerId { replyPeerId = replyAttribute.messageId.peerId } if replyAttribute.isQuote { @@ -1841,9 +1851,19 @@ public final class PendingMessageManager { sendMessageRequest = network.request(Api.functions.messages.sendMedia(flags: flags, peer: inputPeer, replyTo: replyTo, media: inputMedia, message: text, randomId: uniqueId, replyMarkup: nil, entities: messageEntities, scheduleDate: scheduleTime, scheduleRepeatPeriod: scheduleRepeatPeriod, sendAs: sendAsInputPeer, quickReplyShortcut: quickReplyShortcut, effect: messageEffectId, allowPaidStars: allowPaidStars, suggestedPost: suggestedPost), tag: dependencyTag) |> map(NetworkRequestResult.result) case let .forward(sourceInfo): - if topMsgId != nil { - flags |= Int32(1 << 9) + var topMsgId: Int32? + var monoforumPeerId: Api.InputPeer? + if let threadId = message.threadId { + if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.manageDirect) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } + } else { + flags |= Int32(1 << 9) + topMsgId = Int32(clamping: threadId) + } } + var quickReplyShortcut: Api.InputQuickReplyShortcut? if let quickReply { if let threadId = message.threadId { From ea090a6858269642064994e41ae7665e1a4a5e4b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Tue, 28 Apr 2026 23:53:26 +0200 Subject: [PATCH 14/69] Various improvements --- .../Telegram-iOS/en.lproj/Localizable.strings | 26 +++++++++- .../BrowserUI/Sources/BrowserMarkdown.swift | 8 +-- .../Sources/ChatListController.swift | 22 ++++++++ .../Sources/ChatTextLinkEditController.swift | 13 +++++ .../Sources/AdminUserActionsSheet.swift | 10 ++-- .../AlertMultilineInputFieldComponent.swift | 15 +++++- .../ChatMessagePollBubbleContentNode.swift | 51 +++++++++++-------- .../Sources/ChatScheduleTimeScreen.swift | 27 ++++------ .../Sources/ComposePollScreen.swift | 3 +- .../Sources/ContextActionsContainerNode.swift | 3 +- .../Sources/PeerInfoProfileItems.swift | 3 +- .../Sources/PeerInfoScreen.swift | 22 ++++---- .../Sources/PeerInfoSettingsItems.swift | 7 ++- .../QuickReplyNameAlertController.swift | 17 ++++++- .../TelegramUI/Sources/ChatController.swift | 11 +++- 15 files changed, 168 insertions(+), 70 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 41502d52e8..b7e3a23bc7 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16176,6 +16176,7 @@ Error: %8$@"; "CreatePoll.OptionsNeededOne" = "Add at least one option"; "CreatePoll.QuizCorrectOptionNeeded" = "Select a correct option"; "CreatePoll.QuizCorrectOptionNeededMultiple" = "Select at least one correct option"; +"CreatePoll.QuizCountryNeeded" = "Select at least one country"; "Stars.Intro.Transaction.Commission.Title" = "%@ commission"; @@ -16192,7 +16193,7 @@ Error: %8$@"; "CreatePoll.AllowedCountries.Countries_1" = "%@ country"; "CreatePoll.AllowedCountries.Countries_any" = "%@ countries"; -"Chat.Poll.Restriction.Subscribers" = "Only subscribers of **%@** can vote"; +"Chat.Poll.Restriction.Subscribers" = "Only subscribers of **%@** can vote."; "Chat.Poll.Restriction.Subscribers.TimeLimit" = "Only subscribers who joined more than **24 hours** ago can vote."; "Chat.Poll.Restriction.Country" = "Only users from %@ can vote."; "Chat.Poll.Restriction.SubscribersCountry" = "Only subscribers of **%@** from %@ can vote."; @@ -16232,3 +16233,26 @@ Error: %8$@"; "Login.Fee.GetPremiumForDays" = "Get Telegram Premium for %@"; "Login.Fee.GetPremiumForDays.Days_1" = "%@ day"; "Login.Fee.GetPremiumForDays.Days_any" = "%@ days"; + +"PeerInfo.DeleteReaction" = "Delete Reaction"; +"Chat.DeleteReactionInfo" = "Tap and hold to delete reaction."; + +"Chat.AdminActionSheet.DeleteReactionTitle" = "Delete 1 Reaction"; +"Chat.AdminActionSheet.DeleteAllMessages" = "Delete All Messages"; +"Chat.AdminActionSheet.DeleteAllReactions" = "Delete All Reactions"; + +"Conversation.CalendarSearch.Title" = "Search"; +"Conversation.CalendarSearch.Done" = "Done"; + +"ScheduleMessage.SilentPosting.YouEnabled" = "You will receive a silent notification"; +"ScheduleMessage.SilentPosting.YouDisabled" = "You will be notified"; +"ScheduleMessage.SilentPosting.UserEnabled" = "%@ will receive a silent notification"; +"ScheduleMessage.SilentPosting.UserDisabled" = "%@ will be notified"; +"ScheduleMessage.SilentPosting.GroupEnabled" = "Members will receive a silent notification"; +"ScheduleMessage.SilentPosting.GroupDisabled" = "Members will be notified"; +"ScheduleMessage.SilentPosting.ChannelEnabled" = "Subscribers will receive a silent notification"; +"ScheduleMessage.SilentPosting.ChannelDisabled" = "Subscribers will be notified"; + +"Settings.ChatAutomation" = "Chat Automation"; +"Settings.ChatAutomationInfo" = "Add a bot to reply to messages on your behalf."; +"Settings.ChatAutomationOff" = "Off"; diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 6ef7a0eea5..c3723b4075 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -20,6 +20,7 @@ private let markdownInlineHTMLInlineIntent = InlinePresentationIntent(rawValue: private let markdownDefaultBlockImageDimensions = PixelDimensions(width: 1200, height: 900) private let markdownDefaultInlineImageDimensions = PixelDimensions(width: 18, height: 18) +private let markdownImageParsingEnabled = false private let markdownTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" private let markdownTaskListCheckedNumber = "\u{001f}tg-md-task:checked" private let markdownRawHTMLTagRegex = try! NSRegularExpression(pattern: #"]*?>"#) @@ -353,6 +354,9 @@ private final class MarkdownConversionContext { } func resolveImage(attributes: [NSAttributedString.Key: Any]) -> MarkdownResolvedImage? { + guard markdownImageParsingEnabled else { + return nil + } guard let imageUrl = markdownImageURL(attributes: attributes) else { return nil } @@ -1166,10 +1170,8 @@ private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConv switch level { case Int.min ... 1: return [.title(text)] - case 2: - return [.header(text)] default: - return [.heading(text: text, level: Int32(max(3, min(level, 6))))] + return [.heading(text: text, level: Int32(max(2, min(level, 6))))] } case .paragraph: guard let inlineContent = markdownInlineContent(from: node.attributedText, context: context) else { diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 313f684120..c9c94d3288 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -7389,3 +7389,25 @@ private final class AdsInfoContextReferenceContentSource: ContextReferenceConten return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds.inset(by: self.insets), insets: self.contentInsets) } } + +public struct ChatListNavigationTarget { + public let chatListController: ChatListControllerImpl + public let popToController: ViewController? +} + +public func resolveChatListNavigationTarget(navigationController: NavigationController, excluding excludedController: ViewController? = nil) -> ChatListNavigationTarget? { + for case let controller as ViewController in navigationController.viewControllers.reversed() { + if let excludedController, controller === excludedController { + continue + } + if let chatListController = controller as? ChatListControllerImpl, !chatListController.previewing { + return ChatListNavigationTarget(chatListController: chatListController, popToController: controller) + } + } + + if let controller = navigationController.viewControllers.first as? TabBarController, let chatListController = controller.currentController as? ChatListControllerImpl { + return ChatListNavigationTarget(chatListController: chatListController, popToController: nil) + } + + return nil +} diff --git a/submodules/ChatTextLinkEditUI/Sources/ChatTextLinkEditController.swift b/submodules/ChatTextLinkEditUI/Sources/ChatTextLinkEditController.swift index 4229f74605..87e357334b 100644 --- a/submodules/ChatTextLinkEditUI/Sources/ChatTextLinkEditController.swift +++ b/submodules/ChatTextLinkEditUI/Sources/ChatTextLinkEditController.swift @@ -92,5 +92,18 @@ public func chatTextLinkEditController( dismissImpl = { [weak alertController] in alertController?.dismiss(completion: nil) } + + if link == nil { + Queue.mainQueue().after(0.1, { + let pasteboard = UIPasteboard.general + if pasteboard.hasURLs { + if inputState.value.string.isEmpty, let url = pasteboard.url?.absoluteString, !url.isEmpty { + let value = NSAttributedString(string: url) + inputState.setValue(value, selectionRange: 0 ..< value.length) + } + } + }) + } + return alertController } diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index b8bfed14be..2f7a4dc717 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -302,9 +302,8 @@ private func adminUserActionsTitle( } else { return strings.Chat_AdminActionSheet_DeleteTitle(Int32(messageCount)) } - case .chatReaction(_): - //TODO:localize - return "Delete 1 Reaction" + case .chatReaction: + return strings.Chat_AdminActionSheet_DeleteReactionTitle } } @@ -535,14 +534,13 @@ private final class AdminUserActionsContentComponent: Component { )))) } } else { - //TODO:localize subItems.append( AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( theme: component.theme, style: .glass, title: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Delete all Messages", + string: component.strings.Chat_AdminActionSheet_DeleteAllMessages, font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), textColor: component.theme.list.itemPrimaryTextColor )), @@ -568,7 +566,7 @@ private final class AdminUserActionsContentComponent: Component { style: .glass, title: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Delete all Reactions", + string: component.strings.Chat_AdminActionSheet_DeleteAllReactions, font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), textColor: component.theme.list.itemPrimaryTextColor )), diff --git a/submodules/TelegramUI/Components/AlertComponent/AlertMultilineInputFieldComponent/Sources/AlertMultilineInputFieldComponent.swift b/submodules/TelegramUI/Components/AlertComponent/AlertMultilineInputFieldComponent/Sources/AlertMultilineInputFieldComponent.swift index 49f9b1e7d7..1d484546b6 100644 --- a/submodules/TelegramUI/Components/AlertComponent/AlertMultilineInputFieldComponent/Sources/AlertMultilineInputFieldComponent.swift +++ b/submodules/TelegramUI/Components/AlertComponent/AlertMultilineInputFieldComponent/Sources/AlertMultilineInputFieldComponent.swift @@ -19,6 +19,7 @@ public final class AlertMultilineInputFieldComponent: Component { public fileprivate(set) var value: NSAttributedString = NSAttributedString() public fileprivate(set) var animateError: () -> Void = {} public fileprivate(set) var activateInput: () -> Void = {} + fileprivate var setValueImpl: ((NSAttributedString, Range) -> Void)? fileprivate let valuePromise = ValuePromise(NSAttributedString()) public var valueSignal: Signal { return self.valuePromise.get() @@ -32,6 +33,13 @@ public final class AlertMultilineInputFieldComponent: Component { public init() { } + + public func setValue(_ value: NSAttributedString, selectionRange: Range? = nil) { + let selectionRange = selectionRange ?? (value.length ..< value.length) + self.value = value + self.valuePromise.set(value) + self.setValueImpl?(value, selectionRange) + } } public enum FormatMenuAvailability: Equatable { @@ -226,13 +234,18 @@ public final class AlertMultilineInputFieldComponent: Component { func update(component: AlertMultilineInputFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { var resetText: NSAttributedString? if self.component == nil { - resetText = component.initialValue + resetText = component.initialValue ?? (component.externalState.value.length == 0 ? nil : component.externalState.value) component.externalState.animateError = { [weak self] in self?.animateError() } component.externalState.activateInput = { [weak self] in self?.activateInput() } + component.externalState.setValueImpl = { [weak self] value, selectionRange in + if let textFieldView = self?.textField.view as? TextFieldComponent.View { + textFieldView.updateText(value, selectionRange: selectionRange) + } + } } let isFirstTime = self.component == nil diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index d7cd526a60..6e87fb27b6 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2918,31 +2918,42 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if case .public = poll.publicity { item.controllerInteraction.openMessagePollResults(item.message.id, option.opaqueIdentifier) } else if isRestricted { - let locale = localeWithStrings(item.presentationData.strings) - let countryNames = poll.countries.map { id in - if let countryName = locale.localizedString(forRegionCode: id) { - return countryName - } else { - return id - } - } - var countries: String = "" - if countryNames.count == 1, let country = countryNames.first { - countries = "**\(country)**" - } else { - for i in 0 ..< countryNames.count { - countries.append("**\(countryNames[i])**") - if i == countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) - } else if i < countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) + let text: String + + let peerName = item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" + if !poll.countries.isEmpty { + let locale = localeWithStrings(item.presentationData.strings) + let countryNames = poll.countries.map { id in + if let countryName = locale.localizedString(forRegionCode: id) { + return countryName + } else { + return id } } + var countries: String = "" + if countryNames.count == 1, let country = countryNames.first { + countries = "**\(country)**" + } else { + for i in 0 ..< countryNames.count { + countries.append("**\(countryNames[i])**") + if i == countryNames.count - 2 { + countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) + } else if i < countryNames.count - 2 { + countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) + } + } + } + if poll.restrictToSubscribers { + text = item.presentationData.strings.Chat_Poll_Restriction_SubscribersCountry(peerName, countries).string + } else { + text = item.presentationData.strings.Chat_Poll_Restriction_Country(countries).string + } + } else { + text = item.presentationData.strings.Chat_Poll_Restriction_Subscribers(peerName).string } - //TODO:localize let controller = UndoOverlayController( presentationData: item.context.sharedContext.currentPresentationData.with { $0 }, - content: .banned(text: "Only users from \(countries) can vote."), + content: .banned(text: text), elevatedLayout: true, position: .bottom, action: { _ in return true } diff --git a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift index d36aec58d2..58ec1e213a 100644 --- a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift +++ b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift @@ -174,42 +174,39 @@ private final class ChatScheduleTimeSheetContentComponent: Component { let isSilentPosting = self.isSilentPosting let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let peer, let controller = self.environment?.controller() else { + guard let self, let peer, let environment = self.environment, let controller = self.environment?.controller() else { return } - var isChannel = false if case let .channel(channel) = peer, case .broadcast = channel.info { isChannel = true } - - //TODO:localize let text: String if case let .user(user) = peer { if user.id == component.context.account.peerId { if isSilentPosting { - text = "You will receive a silent notification" + text = environment.strings.ScheduleMessage_SilentPosting_YouEnabled } else { - text = "You will be notified" + text = environment.strings.ScheduleMessage_SilentPosting_YouDisabled } } else { if isSilentPosting { - text = "\(peer.compactDisplayTitle) will receive a silent notification" + text = environment.strings.ScheduleMessage_SilentPosting_UserEnabled(peer.compactDisplayTitle).string } else { - text = "\(peer.compactDisplayTitle) will be notified" + text = environment.strings.ScheduleMessage_SilentPosting_UserDisabled(peer.compactDisplayTitle).string } } } else if isChannel { if isSilentPosting { - text = "Subscribers will receive a silent notification" + text = environment.strings.ScheduleMessage_SilentPosting_ChannelEnabled } else { - text = "Subscribers will be notified" + text = environment.strings.ScheduleMessage_SilentPosting_ChannelDisabled } } else { if isSilentPosting { - text = "Members will receive a silent notification" + text = environment.strings.ScheduleMessage_SilentPosting_GroupEnabled } else { - text = "Members will be notified" + text = environment.strings.ScheduleMessage_SilentPosting_GroupDisabled } } @@ -272,8 +269,7 @@ private final class ChatScheduleTimeSheetContentComponent: Component { case .poll: title = strings.CreatePoll_Deadline_Title case .search: - //TODO:localize - title = "Search" + title = strings.Conversation_CalendarSearch_Title } let titleSize = self.title.update( transition: transition, @@ -554,8 +550,7 @@ private final class ChatScheduleTimeSheetContentComponent: Component { case .poll: buttonTitle = strings.CreatePoll_Deadline_SetDeadline case .search: - //TODO:localize - buttonTitle = "Done" + buttonTitle = strings.Conversation_CalendarSearch_Done } let buttonSideInset: CGFloat = 30.0 diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index 42855d33c5..d365dfc98a 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -3099,9 +3099,8 @@ public class ComposePollScreen: ViewControllerComponentContainer, AttachmentCont text = presentationData.strings.CreatePoll_QuizCorrectOptionNeeded } case .countriesNeeded: - //TODO:localize title = nil - text = "Select at least one country" + text = presentationData.strings.CreatePoll_QuizCountryNeeded } let controller = UndoOverlayController( diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift index 09b4cb1251..40aa79c5bf 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift @@ -426,8 +426,7 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode { icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") case .deleteReaction: self.action = nil - //TODO:localize - self.text = "Tap and hold to delete reaction." + self.text = self.presentationData.strings.Chat_DeleteReactionInfo self.targetSelectionIndex = nil icon = nil isUserInteractionEnabled = false diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift index 799802ed35..be77e08247 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift @@ -378,8 +378,7 @@ func infoItems( if let reactionSourceMessageId = reactionSourceMessageId { if canDeleteReaction { - //TODO:localize - items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemDeleteReaction, text: "Delete Reaction", color: .destructive, action: { + items[currentPeerInfoSection]!.append(PeerInfoScreenActionItem(id: ItemDeleteReaction, text: presentationData.strings.PeerInfo_DeleteReaction, color: .destructive, action: { interaction.openDeleteReaction(reactionSourceMessageId) })) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index d388044837..2e441623ba 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -4592,20 +4592,20 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro guard let controller = self.controller, let navigationController = controller.navigationController as? NavigationController else { return } - guard let tabController = navigationController.viewControllers.first as? TabBarController else { + guard let navigationTarget = resolveChatListNavigationTarget(navigationController: navigationController, excluding: controller) else { return } - for childController in tabController.controllers { - if let chatListController = childController as? ChatListController { - chatListController.maybeAskForPeerChatRemoval(peer: EngineRenderedPeer(peer: peer), joined: false, deleteGloballyIfPossible: globally, completion: { [weak navigationController] deleted in - if deleted { - navigationController?.popToRoot(animated: true) - } - }, removed: { - }) - break + navigationTarget.chatListController.maybeAskForPeerChatRemoval(peer: EngineRenderedPeer(peer: peer), joined: false, deleteGloballyIfPossible: globally, completion: { [weak navigationController] deleted in + guard deleted, let navigationController = navigationController else { + return } - } + if let popToController = navigationTarget.popToController { + let _ = navigationController.popToViewController(popToController, animated: true) + } else { + navigationController.popToRoot(animated: true) + } + }, removed: { + }) } func deleteProfilePhoto(_ item: PeerInfoAvatarListItem) { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift index 3bc0da8950..32aa2f6619 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift @@ -496,18 +496,17 @@ func settingsEditingItems(data: PeerInfoScreenData?, state: PeerInfoState, conte } } - //TODO:localize let automationBotTitle: String if let botPeer = data.businessConnectedBot { let _ = botPeer automationBotTitle = "@\(botPeer.compactDisplayTitle)" } else { - automationBotTitle = "Off" + automationBotTitle = presentationData.strings.Settings_ChatAutomationOff } - items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), additionalBadgeLabel: nil, text: "Chat Automation", icon: PresentationResourcesSettings.aiTools, action: { + items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerChatAutomation, label: .text(automationBotTitle), additionalBadgeLabel: nil, text: presentationData.strings.Settings_ChatAutomation, icon: PresentationResourcesSettings.aiTools, action: { interaction.editingOpenBusinessChatBots() })) - items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: "Add a bot to reply to messages on your behalf.")) + items[.info]!.append(PeerInfoScreenCommentItem(id: ItemPeerChatAutomationHelp, text: presentationData.strings.Settings_ChatAutomationInfo)) items[.account]!.append(PeerInfoScreenActionItem(id: ItemAddAccount, text: presentationData.strings.Settings_AddAnotherAccount, alignment: .center, action: { interaction.openSettings(.addAccount) diff --git a/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift index f44510235a..466a1b8bd8 100644 --- a/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift +++ b/submodules/TelegramUI/Components/Settings/QuickReplyNameAlertController/Sources/QuickReplyNameAlertController.swift @@ -49,7 +49,7 @@ public func quickReplyNameAlertController(context: AccountContext, updatedPresen component: AnyComponent( AlertInputFieldComponent( context: context, - initialValue: nil, + initialValue: value, placeholder: strings.QuickReply_ShortcutPlaceholder, characterLimit: characterLimit, hasClearButton: false, @@ -58,6 +58,21 @@ public func quickReplyNameAlertController(context: AccountContext, updatedPresen autocorrectionType: .no, isInitiallyFocused: true, externalState: inputState, + shouldChangeText: { updatedText in + if updatedText.isEmpty { + return true + } + for scalar in updatedText.unicodeScalars { + if scalar.value == 0x5f || scalar.value == 0x200c || scalar.value == 0xb7 || (scalar.value >= 0xd80 && scalar.value <= 0xdff) { + continue + } + if CharacterSet.letters.contains(scalar) || CharacterSet.decimalDigits.contains(scalar) { + continue + } + return false + } + return true + }, returnKeyAction: { applyImpl?() } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 521ea3d5c1..fa5f539165 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -9385,10 +9385,19 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G guard case let .peer(peerId) = self.chatLocation else { return } + let navigationTarget = self.effectiveNavigationController.flatMap { navigationController in + resolveChatListNavigationTarget(navigationController: navigationController, excluding: self) + } self.commitPurposefulAction() self.chatDisplayNode.historyNode.disconnect() let _ = self.context.engine.peers.removePeerChat(peerId: peerId, reportChatSpam: reportChatSpam).startStandalone() - self.effectiveNavigationController?.popToRoot(animated: true) + if let navigationController = self.effectiveNavigationController { + if let navigationTarget, let popToController = navigationTarget.popToController { + let _ = navigationController.popToViewController(popToController, animated: true) + } else { + navigationController.popToRoot(animated: true) + } + } let _ = self.context.engine.privacy.requestUpdatePeerIsBlocked(peerId: peerId, isBlocked: true).startStandalone() } From 7ab4685940f2f20252524d8f6225192b7295be60 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 03:52:19 +0200 Subject: [PATCH 15/69] Various fixes --- submodules/Camera/Sources/Camera.swift | 9 +- submodules/Camera/Sources/CameraDevice.swift | 24 ++-- submodules/Camera/Sources/CameraOutput.swift | 111 +++++++++++++----- .../Camera/Sources/PhotoCaptureContext.swift | 1 + submodules/Camera/Sources/VideoRecorder.swift | 4 +- .../Sources/TGPhotoCaptionInputMixin.m | 10 ++ .../ProxyListSettingsController.swift | 6 +- .../ProxyServerSettingsController.swift | 8 +- .../Sources/CameraVideoSource.swift | 4 +- .../Sources/ChatTextInputPanelNode.swift | 27 +++-- 10 files changed, 140 insertions(+), 64 deletions(-) diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index 6790ba59ec..d879212c91 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -146,7 +146,7 @@ private final class CameraContext { transform = CGAffineTransformTranslate(transform, 0.0, -size.height) ciImage = ciImage.transformed(by: transform) } - ciImage = ciImage.clampedToExtent().applyingGaussianBlur(sigma: Camera.isDualCameraSupported(forRoundVideo: true) ? 100.0 : 40.0).cropped(to: CGRect(origin: .zero, size: size)) + ciImage = ciImage.clampedToExtent().applyingGaussianBlur(sigma: Camera.isDualCameraSupported(forRoundVideo: true) ? 60.0 : 40.0).cropped(to: CGRect(origin: .zero, size: size)) if let cgImage = self.ciContext.createCGImage(ciImage, from: ciImage.extent) { let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: .right) if front { @@ -189,6 +189,7 @@ private final class CameraContext { deinit { Logger.shared.log("CameraContext", "deinit") + NotificationCenter.default.removeObserver(self) } private var isSessionRunning = false @@ -202,7 +203,7 @@ private final class CameraContext { } func stopCapture(invalidate: Bool = false) { - Logger.shared.log("CameraContext", "startCapture(invalidate: \(invalidate))") + Logger.shared.log("CameraContext", "stopCapture(invalidate: \(invalidate))") if invalidate { self.mainDeviceContext?.device.resetZoom() @@ -212,6 +213,7 @@ private final class CameraContext { } self.session.session.stopRunning() + self.isSessionRunning = false } func focus(at point: CGPoint, autoFocus: Bool) { @@ -228,7 +230,7 @@ private final class CameraContext { } func setFps(_ fps: Float64) { - self.mainDeviceContext?.device.fps = fps + self.mainDeviceContext?.device.setFps(fps) } private var modeChange: Camera.ModeChange = .none { @@ -1170,6 +1172,7 @@ public struct CameraRecordingData { } public enum CameraRecordingError { + case videoRecorderInitializationError case audioInitializationError } diff --git a/submodules/Camera/Sources/CameraDevice.swift b/submodules/Camera/Sources/CameraDevice.swift index 6778c2dff5..e07d634d41 100644 --- a/submodules/Camera/Sources/CameraDevice.swift +++ b/submodules/Camera/Sources/CameraDevice.swift @@ -141,10 +141,12 @@ final class CameraDevice { Logger.shared.log("Camera", "No format selected") } + #if DEBUG Logger.shared.log("Camera", "Available formats:") for format in device.formats { Logger.shared.log("Camera", format.description) } + #endif if let targetFPS = device.actualFPS(maxFramerate) { device.activeVideoMinFrameDuration = targetFPS.duration @@ -180,18 +182,16 @@ final class CameraDevice { self.setFocusPoint(CGPoint(x: 0.5, y: 0.5), focusMode: .continuousAutoFocus, exposureMode: .continuousAutoExposure, monitorSubjectAreaChange: false) } - var fps: Double = defaultFPS { - didSet { - guard let device = self.videoDevice, let targetFPS = device.actualFPS(Double(self.fps)) else { - return - } - - self.fps = targetFPS.fps - - self.transaction(device) { device in - device.activeVideoMinFrameDuration = targetFPS.duration - device.activeVideoMaxFrameDuration = targetFPS.duration - } + private(set) var fps: Double = defaultFPS + + func setFps(_ fps: Double) { + guard let device = self.videoDevice, let targetFPS = device.actualFPS(Double(self.fps)) else { + return + } + self.fps = targetFPS.fps + self.transaction(device) { device in + device.activeVideoMinFrameDuration = targetFPS.duration + device.activeVideoMaxFrameDuration = targetFPS.duration } } diff --git a/submodules/Camera/Sources/CameraOutput.swift b/submodules/Camera/Sources/CameraOutput.swift index a1f0a3fb1c..4872e7635b 100644 --- a/submodules/Camera/Sources/CameraOutput.swift +++ b/submodules/Camera/Sources/CameraOutput.swift @@ -93,6 +93,11 @@ public struct CameraCode: Equatable { } final class CameraOutput: NSObject { + private struct RoundVideoFormatDescriptionCacheEntry { + let sourceFormatDescription: CMFormatDescription + let outputFormatDescription: CMFormatDescription + } + let exclusive: Bool let ciContext: CIContext let colorSpace: CGColorSpace @@ -111,13 +116,14 @@ final class CameraOutput: NSObject { private var roundVideoFilter: CameraRoundLegacyVideoFilter? private let semaphore = DispatchSemaphore(value: 1) + private var roundVideoFormatDescriptionCache: [RoundVideoFormatDescriptionCacheEntry] = [] private let videoQueue = DispatchQueue(label: "", qos: .userInitiated) private let audioQueue = DispatchQueue(label: "") private let metadataQueue = DispatchQueue(label: "") - private var photoCaptureRequests: [Int64: PhotoCaptureContext] = [:] + private var photoCaptureRequests = Atomic<[Int64: PhotoCaptureContext]>(value: [:]) private var videoRecorder: VideoRecorder? private var captureOrientation: AVCaptureVideoOrientation = .portrait @@ -268,8 +274,8 @@ final class CameraOutput: NSObject { return EmptyDisposable } subscriber.putNext(self.photoOutput.isFlashScene) - let observer = self.photoOutput.observe(\.isFlashScene, options: [.new], changeHandler: { device, _ in - subscriber.putNext(self.photoOutput.isFlashScene) + let observer = self.photoOutput.observe(\.isFlashScene, options: [.new], changeHandler: { output, _ in + subscriber.putNext(output.isFlashScene) }) return ActionDisposable { observer.invalidate() @@ -316,12 +322,20 @@ final class CameraOutput: NSObject { #else let uniqueId = settings.uniqueID let photoCapture = PhotoCaptureContext(ciContext: self.ciContext, settings: settings, orientation: orientation, mirror: mirror) - self.photoCaptureRequests[uniqueId] = photoCapture + let _ = self.photoCaptureRequests.modify { dict in + var dict = dict + dict[uniqueId] = photoCapture + return dict + } self.photoOutput.capturePhoto(with: settings, delegate: photoCapture) return photoCapture.signal |> afterDisposed { [weak self] in - self?.photoCaptureRequests.removeValue(forKey: uniqueId) + let _ = self?.photoCaptureRequests.modify { dict in + var dict = dict + dict.removeValue(forKey: uniqueId) + return dict + } } #endif } @@ -419,18 +433,21 @@ final class CameraOutput: NSObject { } } ) + guard let videoRecorder else { + return .fail(.videoRecorderInitializationError) + } - videoRecorder?.start() + videoRecorder.start() self.videoRecorder = videoRecorder if case .dualCamera = mode, let position { - videoRecorder?.markPositionChange(position: position, time: .zero) + videoRecorder.markPositionChange(position: position, time: .zero) } else if case .roundVideo = mode { additionalOutput?.masterOutput = self } return Signal { subscriber in - let timer = SwiftSignalKit.Timer(timeout: 0.033, repeat: true, completion: { [weak videoRecorder] in + let timer = SwiftSignalKit.Timer(timeout: 0.1, repeat: true, completion: { [weak videoRecorder] in let recordingData = CameraRecordingData(duration: videoRecorder?.duration ?? 0.0, filePath: outputFilePath) subscriber.putNext(recordingData) }, queue: Queue.mainQueue()) @@ -456,13 +473,54 @@ final class CameraOutput: NSObject { } var transitionImage: UIImage? { - return self.videoRecorder?.transitionImage + var result: UIImage? + self.videoQueue.sync { + result = self.videoRecorder?.transitionImage + } + return result } private weak var masterOutput: CameraOutput? private var lastSampleTimestamp: CMTime? + private func roundVideoFormatDescription(for sourceFormatDescription: CMFormatDescription) -> CMFormatDescription? { + if let entry = self.roundVideoFormatDescriptionCache.first(where: { CFEqual($0.sourceFormatDescription, sourceFormatDescription) }) { + return entry.outputFormatDescription + } + + guard let extensions = CMFormatDescriptionGetExtensions(sourceFormatDescription) as? [String: Any] else { + return nil + } + + let mediaSubType = CMFormatDescriptionGetMediaSubType(sourceFormatDescription) + var updatedExtensions = extensions + updatedExtensions["CVBytesPerRow"] = videoMessageDimensions.width * 4 + + var outputFormatDescription: CMFormatDescription? + let status = CMVideoFormatDescriptionCreate( + allocator: nil, + codecType: mediaSubType, + width: videoMessageDimensions.width, + height: videoMessageDimensions.height, + extensions: updatedExtensions as CFDictionary, + formatDescriptionOut: &outputFormatDescription + ) + guard status == noErr, let outputFormatDescription else { + return nil + } + + self.roundVideoFormatDescriptionCache.append(RoundVideoFormatDescriptionCacheEntry( + sourceFormatDescription: sourceFormatDescription, + outputFormatDescription: outputFormatDescription + )) + if self.roundVideoFormatDescriptionCache.count > 4 { + self.roundVideoFormatDescriptionCache.removeFirst(self.roundVideoFormatDescriptionCache.count - 4) + } + + return outputFormatDescription + } + private var needsCrossfadeTransition = false private var crossfadeTransitionStart: Double = 0.0 @@ -564,17 +622,11 @@ final class CameraOutput: NSObject { return nil } self.semaphore.wait() - - let mediaSubType = CMFormatDescriptionGetMediaSubType(formatDescription) - let extensions = CMFormatDescriptionGetExtensions(formatDescription) as! [String: Any] - - var updatedExtensions = extensions - updatedExtensions["CVBytesPerRow"] = videoMessageDimensions.width * 4 - - var newFormatDescription: CMFormatDescription? - var status = CMVideoFormatDescriptionCreate(allocator: nil, codecType: mediaSubType, width: videoMessageDimensions.width, height: videoMessageDimensions.height, extensions: updatedExtensions as CFDictionary, formatDescriptionOut: &newFormatDescription) - guard status == noErr, let newFormatDescription else { + defer { self.semaphore.signal() + } + + guard let newFormatDescription = self.roundVideoFormatDescription(for: formatDescription) else { return nil } @@ -585,12 +637,11 @@ final class CameraOutput: NSObject { filter = CameraRoundLegacyVideoFilter(ciContext: self.ciContext, colorSpace: self.colorSpace, simple: self.exclusive) self.roundVideoFilter = filter } - if !filter.isPrepared { + if !filter.isPrepared || filter.inputFormatDescription.map({ !CFEqual($0, newFormatDescription) }) ?? true { filter.prepare(with: newFormatDescription, outputRetainedBufferCountHint: 4) } guard let newPixelBuffer = filter.render(pixelBuffer: videoPixelBuffer, additional: additional, captureOrientation: self.captureOrientation, transitionFactor: transitionFactor) else { - self.semaphore.signal() return nil } @@ -603,7 +654,7 @@ final class CameraOutput: NSObject { } var newSampleBuffer: CMSampleBuffer? - status = CMSampleBufferCreateForImageBuffer( + let status = CMSampleBufferCreateForImageBuffer( allocator: kCFAllocatorDefault, imageBuffer: newPixelBuffer, dataReady: true, @@ -615,10 +666,8 @@ final class CameraOutput: NSObject { ) if status == noErr, let newSampleBuffer { - self.semaphore.signal() return newSampleBuffer } - self.semaphore.signal() return nil } @@ -640,18 +689,18 @@ extension CameraOutput: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureA guard CMSampleBufferDataIsReady(sampleBuffer) else { return } - - if let videoPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { - self.processSampleBuffer?(sampleBuffer, videoPixelBuffer, connection) - } else if sampleBuffer.type == kCMMediaType_Audio { - self.processAudioBuffer?(sampleBuffer) - } - + if let masterOutput = self.masterOutput { masterOutput.processVideoRecording(sampleBuffer, fromAdditionalOutput: true) } else { self.processVideoRecording(sampleBuffer, fromAdditionalOutput: false) } + + if let videoPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) { + self.processSampleBuffer?(sampleBuffer, videoPixelBuffer, connection) + } else if sampleBuffer.type == kCMMediaType_Audio { + self.processAudioBuffer?(sampleBuffer) + } } func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { diff --git a/submodules/Camera/Sources/PhotoCaptureContext.swift b/submodules/Camera/Sources/PhotoCaptureContext.swift index c61fe73602..3e34fc5904 100644 --- a/submodules/Camera/Sources/PhotoCaptureContext.swift +++ b/submodules/Camera/Sources/PhotoCaptureContext.swift @@ -56,6 +56,7 @@ final class PhotoCaptureContext: NSObject, AVCapturePhotoCaptureDelegate { } else { guard let photoPixelBuffer = photo.pixelBuffer else { print("Error occurred while capturing photo: Missing pixel buffer (\(String(describing: error)))") + self.pipe.putNext(.failed) return } diff --git a/submodules/Camera/Sources/VideoRecorder.swift b/submodules/Camera/Sources/VideoRecorder.swift index a2d3a7cb83..5748938a75 100644 --- a/submodules/Camera/Sources/VideoRecorder.swift +++ b/submodules/Camera/Sources/VideoRecorder.swift @@ -204,9 +204,7 @@ private final class VideoRecorderImpl { let maxDate = Date(timeIntervalSinceNow: 0.05) RunLoop.current.run(until: maxDate) } - } - if let videoInput = self.videoInput { let time = CACurrentMediaTime() // if let previousPresentationTime = self.previousPresentationTime, let previousAppendTime = self.previousAppendTime { // print("appending \(presentationTime.seconds) (\(presentationTime.seconds - previousPresentationTime) ) on \(time) (\(time - previousAppendTime)") @@ -366,7 +364,7 @@ private final class VideoRecorderImpl { private func maybeFinish() { dispatchPrecondition(condition: .onQueue(self.queue)) - guard self.hasAllVideoBuffers && self.hasAllVideoBuffers && !self.stopped else { + guard self.hasAllVideoBuffers && (!self.configuration.hasAudio || self.hasAllAudioBuffers) && !self.stopped else { return } let _ = self._stopped.modify { _ in return true } diff --git a/submodules/LegacyComponents/Sources/TGPhotoCaptionInputMixin.m b/submodules/LegacyComponents/Sources/TGPhotoCaptionInputMixin.m index a0bb1df327..f92214b2b1 100644 --- a/submodules/LegacyComponents/Sources/TGPhotoCaptionInputMixin.m +++ b/submodules/LegacyComponents/Sources/TGPhotoCaptionInputMixin.m @@ -138,6 +138,10 @@ - (void)createDismissViewIfNeeded { + if (_dismissView != nil) { + return; + } + UIView *parentView = [self _parentView]; _dismissView = [[UIView alloc] initWithFrame:parentView.bounds]; @@ -231,6 +235,12 @@ - (void)finishEditing { if ([self.inputPanel dismissInput]) { _editing = false; + + [UIView animateWithDuration:0.3 delay:0.0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + _dismissView.alpha = 0.0f; + } completion:^(BOOL finished) { + + }]; if (self.finishedWithCaption != nil) self.finishedWithCaption([_inputPanel caption]); diff --git a/submodules/SettingsUI/Sources/Data and Storage/ProxyListSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/ProxyListSettingsController.swift index 44e310c99e..1e3adc0e28 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/ProxyListSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/ProxyListSettingsController.swift @@ -260,7 +260,11 @@ private func proxySettingsControllerEntries(theme: PresentationTheme, strings: P entries.append(.serversHeader(theme, strings.SocksProxySetup_SavedProxies)) entries.append(.addServer(theme, strings.SocksProxySetup_AddProxy, state.editing)) var index = 0 + var existingServers = Set() for server in proxySettings.servers { + if !existingServers.insert(server).inserted { + continue + } let status: ProxyServerStatus = statuses[server] ?? .checking let displayStatus: DisplayProxyServerStatus if proxySettings.enabled && server == proxySettings.activeServer { @@ -301,7 +305,7 @@ private func proxySettingsControllerEntries(theme: PresentationTheme, strings: P entries.append(.server(index, theme, strings, server, server == proxySettings.activeServer, displayStatus, ProxySettingsServerItemEditing(editable: true, editing: state.editing, revealed: state.revealedServer == server), proxySettings.enabled)) index += 1 } - if !proxySettings.servers.isEmpty { + if !existingServers.isEmpty { entries.append(.shareProxyList(theme, strings.SocksProxySetup_ShareProxyList)) } diff --git a/submodules/SettingsUI/Sources/Data and Storage/ProxyServerSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/ProxyServerSettingsController.swift index 62f826c99e..560bd3fce2 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/ProxyServerSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/ProxyServerSettingsController.swift @@ -347,8 +347,11 @@ func proxyServerSettingsController(sharedContext: SharedAccountContext, context: } } } else { - settings.servers.append(proxyServerSettings) - if settings.servers.count == 1 { + let wasEmpty = settings.servers.isEmpty + if !settings.servers.contains(proxyServerSettings) { + settings.servers.append(proxyServerSettings) + } + if wasEmpty && settings.servers.count == 1 { settings.activeServer = proxyServerSettings } } @@ -388,4 +391,3 @@ func proxyServerSettingsController(sharedContext: SharedAccountContext, context: return controller } - diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraVideoSource.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraVideoSource.swift index c1d2974745..c06460e8dc 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraVideoSource.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraVideoSource.swift @@ -34,7 +34,7 @@ final class CameraVideoSource: VideoSource { let index = self.onUpdatedListeners.add(f) return ActionDisposable { [weak self] in - DispatchQueue.main.async { + Queue.mainQueue().async { guard let self else { return } @@ -252,7 +252,7 @@ final class LiveStreamMediaSource { let index = self.onVideoUpdatedListeners.add(f) return ActionDisposable { [weak self] in - DispatchQueue.main.async { + Queue.mainQueue().async { guard let self else { return } diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index 4a74571527..aace91290f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -3427,6 +3427,7 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } } if let mediaDraftState = interfaceState.interfaceState.mediaDraftState, case .audio = mediaDraftState.contentType { + viewOnceIsVisible = true recordMoreIsVisible = true } @@ -3438,13 +3439,29 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg transition.updateSublayerTransformOffset(layer: self.clippingNode.layer, offset: CGPoint(x: 0.0, y: clippingDelta))*/ let viewOnceSize = self.viewOnceButton.update(theme: interfaceState.theme) - let viewOnceButtonFrame = CGRect(origin: CGPoint(x: width - rightInset - 50.0 - UIScreenPixel, y: -152.0), size: viewOnceSize) + + var viewOnceButtonY: CGFloat = -105.0 + if isRecording { + if accessoryPanel == nil { + viewOnceButtonY -= 49.0 + } + } + + let viewOnceButtonFrame = CGRect(origin: CGPoint(x: width - rightInset - 50.0 - UIScreenPixel, y: viewOnceButtonY), size: viewOnceSize) self.viewOnceButton.bounds = CGRect(origin: .zero, size: viewOnceButtonFrame.size) transition.updatePosition(node: self.viewOnceButton, position: viewOnceButtonFrame.center) if self.viewOnceButton.alpha.isZero && viewOnceIsVisible { self.viewOnceButton.update(isSelected: self.viewOnce, animated: false) } + + transition.updateAlpha(node: self.viewOnceButton, alpha: viewOnceIsVisible ? 1.0 : 0.0) + transition.updateTransformScale(node: self.viewOnceButton, scale: viewOnceIsVisible ? 1.0 : 0.01) + if let user = interfaceState.renderedPeer?.peer as? TelegramUser, user.id != interfaceState.accountPeerId && user.botInfo == nil && interfaceState.sendPaidMessageStars == nil { + self.viewOnceButton.isHidden = false + } else { + self.viewOnceButton.isHidden = true + } let recordMoreSize = self.recordMoreButton.update(theme: interfaceState.theme) let recordMoreButtonFrame = CGRect(origin: CGPoint(x: width - rightInset - 50.0 - UIScreenPixel, y: -52.0), size: recordMoreSize) @@ -3455,14 +3472,6 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg self.recordMoreButton.update(isSelected: false, animated: false) } - transition.updateAlpha(node: self.viewOnceButton, alpha: viewOnceIsVisible ? 1.0 : 0.0) - transition.updateTransformScale(node: self.viewOnceButton, scale: viewOnceIsVisible ? 1.0 : 0.01) - if let user = interfaceState.renderedPeer?.peer as? TelegramUser, user.id != interfaceState.accountPeerId && user.botInfo == nil && interfaceState.sendPaidMessageStars == nil { - self.viewOnceButton.isHidden = false - } else { - self.viewOnceButton.isHidden = true - } - transition.updateAlpha(node: self.recordMoreButton, alpha: recordMoreIsVisible ? 1.0 : 0.0) transition.updateTransformScale(node: self.recordMoreButton, scale: recordMoreIsVisible ? 1.0 : 0.01) From 837acfa7842005c1c71f3bfb0c7f404099fb4d6a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 04:26:58 +0200 Subject: [PATCH 16/69] Various fixes --- .../Sources/ChatTextInputPanelNode.swift | 3 --- .../ComposePollScreen/Sources/ComposePollScreen.swift | 3 ++- .../Sources/CountriesMultiselectionScreen.swift | 11 +++++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift index aace91290f..fcb5d7a3cb 100644 --- a/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift @@ -5610,9 +5610,6 @@ public class ChatTextInputPanelNode: ChatInputPanelNode, ASEditableTextNodeDeleg } public func frameForInputActionButton() -> CGRect? { - if !self.sendActionButtons.alpha.isZero && self.sendActionButtons.frame.minX < self.bounds.width { - return self.sendActionButtons.frame.insetBy(dx: 0.0, dy: -4.0).offsetBy(dx: -3.0, dy: 0.0) - } if !self.mediaActionButtons.alpha.isZero && self.mediaActionButtons.frame.minX < self.bounds.width { return self.mediaActionButtons.frame.insetBy(dx: 0.0, dy: -4.0).offsetBy(dx: -3.0, dy: 0.0) } diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index d365dfc98a..c6f4d2a4f4 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -1248,7 +1248,8 @@ final class ComposePollScreenComponent: Component { let stateContext = CountriesMultiselectionScreen.StateContext( context: component.context, subject: .countries, - initialSelectedCountries: self.limitToCountries + initialSelectedCountries: self.limitToCountries, + showFragment: true ) let _ = (stateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).startStandalone(next: { [weak self] _ in let controller = CountriesMultiselectionScreen( diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift index 5f2781e32d..769bd06411 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -1122,6 +1122,7 @@ public extension CountriesMultiselectionScreen { public let subject: Subject public let maxCount: Int32? public let initialSelectedCountries: [String] + public let showFragment: Bool private var stateDisposable: Disposable? private let stateSubject = Promise() @@ -1138,14 +1139,20 @@ public extension CountriesMultiselectionScreen { context: AccountContext, subject: Subject = .countries, maxCount: Int32? = nil, - initialSelectedCountries: [String] = [] + initialSelectedCountries: [String] = [], + showFragment: Bool = false ) { self.subject = subject self.maxCount = maxCount self.initialSelectedCountries = initialSelectedCountries + self.showFragment = showFragment let presentationData = context.sharedContext.currentPresentationData.with { $0 } - let countries = localizedCountryNamesAndCodes(strings: presentationData.strings).sorted { lhs, rhs in + var countryList = localizedCountryNamesAndCodes(strings: presentationData.strings) + if showFragment { + countryList.append((("Fragment", "Fragment"), "FT", [888])) + } + let countries = countryList.sorted { lhs, rhs in return lhs.0.1.lowercased() < rhs.0.1.lowercased() } From 5d7edcf36f189691bd7bca8e98828459da687e91 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 04:35:17 +0200 Subject: [PATCH 17/69] Various fixes --- .../Sources/ChatMessagePollBubbleContentNode.swift | 4 +++- .../ComposePollScreen/Sources/ComposePollScreen.swift | 6 +++++- .../Sources/CountriesMultiselectionScreen.swift | 2 +- .../TelegramUI/Sources/ChatInterfaceStateContextMenus.swift | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 6e87fb27b6..873a2a6dab 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2924,7 +2924,9 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if !poll.countries.isEmpty { let locale = localeWithStrings(item.presentationData.strings) let countryNames = poll.countries.map { id in - if let countryName = locale.localizedString(forRegionCode: id) { + if id == "FT" { + return "Fragment" + } else if let countryName = locale.localizedString(forRegionCode: id) { return countryName } else { return id diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index c6f4d2a4f4..d7b6393f44 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -2396,7 +2396,11 @@ final class ComposePollScreenComponent: Component { if self.limitToCountries.count > 1 { value = environment.strings.CreatePoll_AllowedCountries_Countries(Int32(self.limitToCountries.count)) } else if self.limitToCountries.count == 1, let countryCode = self.limitToCountries.first { - value = self.currentLocale?.localizedString(forRegionCode: countryCode) ?? countryCode + if countryCode == "FT" { + value = "Fragment" + } else { + value = self.currentLocale?.localizedString(forRegionCode: countryCode) ?? countryCode + } } else { value = "" } diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift index 769bd06411..4c8cda887a 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -1163,7 +1163,7 @@ public extension CountriesMultiselectionScreen { var currentSection: String? var currentCountries: [CountryItem] = [] for country in countries { - let section = String(country.0.1.prefix(1)) + let section = String(country.0.1.prefix(1)).uppercased() if currentSection != section { if let currentSection { sections.append((currentSection, currentCountries)) diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index e6a8b66608..ee3c2eeaee 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -2310,7 +2310,9 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState if !poll.countries.isEmpty { let locale = localeWithStrings(chatPresentationInterfaceState.strings) let countryNames = poll.countries.map { id in - if let countryName = locale.localizedString(forRegionCode: id) { + if id == "FT" { + return "Fragment" + } else if let countryName = locale.localizedString(forRegionCode: id) { return countryName } else { return id From d25010c1cbfbacddf2482c30af805af5cb1dc1e7 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 12:46:08 +0200 Subject: [PATCH 18/69] Various fixes --- .../State/AccountStateManagementUtils.swift | 18 ++++++++- .../TelegramEngine/Peers/RecentPeers.swift | 22 ++++++----- .../Sources/ComposePollScreen.swift | 8 ++++ .../CountriesMultiselectionScreen.swift | 15 ++++--- .../Sources/ChatHistoryListNode.swift | 39 +++++++++++-------- 5 files changed, 66 insertions(+), 36 deletions(-) diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index adc7c8fe68..4e0ca6e007 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -3309,7 +3309,7 @@ func resetChannels(accountPeerId: PeerId, postbox: Postbox, network: Network, pe resetForumTopics.insert(peerId) } - + for message in messages { var peerIsForum = false if let peerId = message.peerId { @@ -3943,6 +3943,7 @@ func replayFinalState( var updatedStarGiftAuctionState: [Int64: GiftAuctionContext.State.AuctionState] = [:] var updatedStarGiftAuctionMyState: [Int64: GiftAuctionContext.State.MyState] = [:] var updatedEmojiGameInfo: EmojiGameInfo? + var recentlyUsedGuestChatBots = Set() var holesFromPreviousStateMessageIds: [MessageId] = [] var clearHolesFromPreviousStateForChannelMessagesWithPts: [PeerIdAndMessageNamespace: Int32] = [:] @@ -4341,6 +4342,15 @@ func replayFinalState( } } } + + if message.flags.contains(.Incoming), let authorId = message.authorId { + for attribute in message.attributes { + if let attribute = attribute as? GuestChatMessageAttribute, attribute.peerId == accountPeerId { + recentlyUsedGuestChatBots.insert(authorId) + break + } + } + } } if !message.flags.contains(.Incoming) && !message.flags.contains(.Unsent) { if message.id.peerId.namespace == Namespaces.Peer.CloudChannel { @@ -4350,7 +4360,7 @@ func replayFinalState( if !message.flags.contains(.Incoming), message.forwardInfo == nil { if [Namespaces.Peer.CloudGroup, Namespaces.Peer.CloudChannel].contains(message.id.peerId.namespace), let peer = transaction.getPeer(message.id.peerId), peer.isCopyProtectionEnabled { - + } else if message.id.peerId.namespace == Namespaces.Peer.CloudUser, let cachedUserData = transaction.getPeerCachedData(peerId: message.id.peerId) as? CachedUserData, cachedUserData.flags.contains(.copyProtectionEnabled) || cachedUserData.flags.contains(.myCopyProtectionEnabled) { } else { @@ -5867,6 +5877,10 @@ func replayFinalState( } } + for peerId in recentlyUsedGuestChatBots { + _internal_addRecentlyUsedInlineBot(transaction: transaction, peerId: peerId) + } + if syncAttachMenuBots { // addSynchronizeAttachMenuBotsOperation(transaction: transaction) } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift index bee4cf3f35..fe9e694a9f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/RecentPeers.swift @@ -211,17 +211,21 @@ func _internal_managedRecentlyUsedInlineBots(postbox: Postbox, network: Network, return updatedRemotePeers } +func _internal_addRecentlyUsedInlineBot(transaction: Transaction, peerId: PeerId) { + var maxRating = 1.0 + for entry in transaction.getOrderedListItems(collectionId: Namespaces.OrderedItemList.CloudRecentInlineBots) { + if let contents = entry.contents.get(RecentPeerItem.self) { + maxRating = max(maxRating, contents.rating) + } + } + if let entry = CodableEntry(RecentPeerItem(rating: maxRating)) { + transaction.addOrMoveToFirstPositionOrderedItemListItem(collectionId: Namespaces.OrderedItemList.CloudRecentInlineBots, item: OrderedItemListEntry(id: RecentPeerItemId(peerId).rawValue, contents: entry), removeTailIfCountExceeds: 20) + } +} + func _internal_addRecentlyUsedInlineBot(postbox: Postbox, peerId: PeerId) -> Signal { return postbox.transaction { transaction -> Void in - var maxRating = 1.0 - for entry in transaction.getOrderedListItems(collectionId: Namespaces.OrderedItemList.CloudRecentInlineBots) { - if let contents = entry.contents.get(RecentPeerItem.self) { - maxRating = max(maxRating, contents.rating) - } - } - if let entry = CodableEntry(RecentPeerItem(rating: maxRating)) { - transaction.addOrMoveToFirstPositionOrderedItemListItem(collectionId: Namespaces.OrderedItemList.CloudRecentInlineBots, item: OrderedItemListEntry(id: RecentPeerItemId(peerId).rawValue, contents: entry), removeTailIfCountExceeds: 20) - } + _internal_addRecentlyUsedInlineBot(transaction: transaction, peerId: peerId) } } diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index d7b6393f44..d200bcaf60 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -1245,9 +1245,17 @@ final class ComposePollScreenComponent: Component { return } + let maxCount: Int32 + if let data = component.context.currentAppConfiguration.with({ $0 }).data, let value = data["poll_countries_max"] as? Double { + maxCount = Int32(value) + } else { + maxCount = 12 + } + let stateContext = CountriesMultiselectionScreen.StateContext( context: component.context, subject: .countries, + maxCount: maxCount, initialSelectedCountries: self.limitToCountries, showFragment: true ) diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift index 4c8cda887a..3c3ad2573a 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -752,7 +752,7 @@ final class CountriesMultiselectionScreenComponent: Component { if let searchStateContext = self.searchStateContext, searchStateContext.subject == .countriesSearch(query: self.navigationTextFieldState.text) { } else { self.searchStateDisposable?.dispose() - let searchStateContext = CountriesMultiselectionScreen.StateContext(context: component.context, subject: .countriesSearch(query: self.navigationTextFieldState.text)) + let searchStateContext = CountriesMultiselectionScreen.StateContext(context: component.context, subject: .countriesSearch(query: self.navigationTextFieldState.text), showFragment: component.stateContext.showFragment) var applyState = false self.searchStateDisposable = (searchStateContext.ready |> filter { $0 } |> take(1) |> deliverOnMainQueue).start(next: { [weak self] _ in guard let self else { @@ -789,7 +789,6 @@ final class CountriesMultiselectionScreenComponent: Component { var sections: [ItemLayout.Section] = [] if let stateValue = self.effectiveStateValue { - var id: Int = 0 for (_, countries) in stateValue.sections { sections.append(ItemLayout.Section( @@ -1148,13 +1147,13 @@ public extension CountriesMultiselectionScreen { self.showFragment = showFragment let presentationData = context.sharedContext.currentPresentationData.with { $0 } - var countryList = localizedCountryNamesAndCodes(strings: presentationData.strings) - if showFragment { - countryList.append((("Fragment", "Fragment"), "FT", [888])) - } - let countries = countryList.sorted { lhs, rhs in + let countryList = localizedCountryNamesAndCodes(strings: presentationData.strings) + var countries = countryList.sorted { lhs, rhs in return lhs.0.1.lowercased() < rhs.0.1.lowercased() } + if showFragment, let index = countries.firstIndex(where: { $0.1 == "FR" }) { + countries.insert((("Fragment", "Fragment"), "FT", [888]), at: index) + } switch subject { case .countries: @@ -1164,7 +1163,7 @@ public extension CountriesMultiselectionScreen { var currentCountries: [CountryItem] = [] for country in countries { let section = String(country.0.1.prefix(1)).uppercased() - if currentSection != section { + if currentSection != section && country.1 != "FT" { if let currentSection { sections.append((currentSection, currentCountries)) } diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 089a3a00d6..8927a5af72 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -1724,27 +1724,32 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH } |> distinctUntilChanged - let accountCountry: Signal = .single(nil) - |> then( - combineLatest( - accountPeer - |> map { peer -> String? in - if case let .user(user) = peer { - return user.phone - } else { + let accountCountry: Signal + if let data = context.currentAppConfiguration.with({ $0 }).data, let country = data["phone_country_iso2"] as? String { + accountCountry = .single(country) + } else { + accountCountry = .single(nil) + |> then( + combineLatest( + accountPeer + |> map { peer -> String? in + if case let .user(user) = peer { + return user.phone + } else { + return nil + } + } + |> distinctUntilChanged, + (context as! AccountContextImpl).countriesConfiguration + ) + |> map { phone, countriesConfiguration in + guard let phone, let (country, _) = lookupCountryIdByNumber(phone, configuration: countriesConfiguration) else { return nil } + return country.id } - |> distinctUntilChanged, - (context as! AccountContextImpl).countriesConfiguration ) - |> map { phone, countriesConfiguration in - guard let phone, let (country, _) = lookupCountryIdByNumber(phone, configuration: countriesConfiguration) else { - return nil - } - return country.id - } - ) + } let topicAuthorId: Signal if let peerId = chatLocation.peerId, let threadId = chatLocation.threadId { From 96089108cabae924b80326d3aa147aeddc857078 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 16:55:37 +0400 Subject: [PATCH 19/69] Fixes --- .../State/AccountStateManagementUtils.swift | 9 ++++++ .../Metal/VoiceChatActionButtonShaders.metal | 9 ------ .../Sources/TextProcessingScreen.swift | 32 +++++++++---------- .../Sources/TextStyleEditScreen.swift | 2 +- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index adc7c8fe68..d82675de53 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -2806,6 +2806,15 @@ func extractEmojiFileIds(message: StoreMessage, fileIds: inout Set) { } } } + for media in message.media { + if let media = media as? TelegramMediaWebpage, case let .Loaded(content) = media.content { + for attribute in content.attributes { + if case let .aiTextStyle(aiTextStyle) = attribute { + fileIds.insert(aiTextStyle.emojiFileId) + } + } + } + } } private func messagesFromOperations(state: AccountMutableState) -> [StoreMessage] { diff --git a/submodules/TelegramUI/Components/Calls/VoiceChatActionButton/Metal/VoiceChatActionButtonShaders.metal b/submodules/TelegramUI/Components/Calls/VoiceChatActionButton/Metal/VoiceChatActionButtonShaders.metal index 8834ab1672..0ccd808798 100644 --- a/submodules/TelegramUI/Components/Calls/VoiceChatActionButton/Metal/VoiceChatActionButtonShaders.metal +++ b/submodules/TelegramUI/Components/Calls/VoiceChatActionButton/Metal/VoiceChatActionButtonShaders.metal @@ -7,15 +7,6 @@ struct Rectangle { float2 size; }; -constant static float2 quadVertices[6] = { - float2(0.0, 0.0), - float2(1.0, 0.0), - float2(0.0, 1.0), - float2(1.0, 0.0), - float2(0.0, 1.0), - float2(1.0, 1.0) -}; - struct QuadVertexOut { float4 position [[position]]; float2 uv; diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index acbec41187..2a17a4afbe 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -369,7 +369,7 @@ final class TextProcessingContentComponent: Component { )) } items.append(.action(ContextMenuActionItem( - text: "Share Style", + text: "Share Style", //TODO:localize icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, @@ -382,23 +382,21 @@ final class TextProcessingContentComponent: Component { }) }) )) - if style.isAuthor { - items.append(.action(ContextMenuActionItem( - text: "Delete Style", - textColor: .destructive, - icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) - }, - action: { [weak self] c, _ in - c?.dismiss(completion: { [weak self] in - guard let self else { - return - } - self.requestDeleteStyle(id: id) - }) + items.append(.action(ContextMenuActionItem( + text: "Delete Style", + textColor: .destructive, + icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) + }, + action: { [weak self] c, _ in + c?.dismiss(completion: { [weak self] in + guard let self else { + return + } + self.requestDeleteStyle(id: id) }) - )) - } + }) + )) final class ContextExtractedContentSourceImpl: ContextExtractedContentSource { let keepInPlace: Bool = false diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift index 3c1fbb09f3..88e0e2fa9d 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift @@ -889,7 +889,7 @@ public class TextStyleEditScreen: ViewControllerComponentContainer { var initialEmojiFile: TelegramMediaFile? if case let .edit(style) = mode, case let .custom(style) = style.content, let emojiFileId = style.emojiFileId { - initialEmojiFile = await context.engine.stickers.resolveInlineStickers(fileIds: [emojiFileId]).get()[emojiFileId] + initialEmojiFile = await context.engine.stickers.resolveInlineStickersLocal(fileIds: [emojiFileId]).get()[emojiFileId] } super.init( From 37d1e2d93e8eea99ce83658b2bd5b2eede31043d Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 15:49:21 +0200 Subject: [PATCH 20/69] Various fixes --- submodules/Camera/Sources/Camera.swift | 19 +++++++++++++++- submodules/Camera/Sources/CameraDevice.swift | 22 ++++++++++++++----- submodules/Camera/Sources/CameraInput.swift | 13 ++++++----- submodules/Camera/Sources/CameraOutput.swift | 14 +++++------- submodules/Camera/Sources/VideoRecorder.swift | 18 ++++++++------- .../Sources/TGCameraController.m | 4 ++++ .../Sources/VideoMessageCameraScreen.swift | 12 ++++++---- 7 files changed, 69 insertions(+), 33 deletions(-) diff --git a/submodules/Camera/Sources/Camera.swift b/submodules/Camera/Sources/Camera.swift index d879212c91..e2353ce62b 100644 --- a/submodules/Camera/Sources/Camera.swift +++ b/submodules/Camera/Sources/Camera.swift @@ -278,7 +278,6 @@ private final class CameraContext { self._positionPromise.set(targetPosition) self.modeChange = .position - let preferWide = self.initialConfiguration.preferWide || isRoundVideo let preferLowerFramerate = self.initialConfiguration.preferLowerFramerate || isRoundVideo @@ -567,6 +566,11 @@ private final class CameraContext { return .finished(mainImage, additionalImage, CACurrentMediaTime()) } } else { + if case .failed = main { + return .failed + } else if case .failed = additional { + return .failed + } return .began } } |> distinctUntilChanged @@ -585,6 +589,10 @@ private final class CameraContext { mainDeviceContext.device.setTorchMode(self._flashMode) } + let timestamp = CACurrentMediaTime() + 2.0 + self.lastSnapshotTimestamp = timestamp + self.lastAdditionalSnapshotTimestamp = timestamp + let orientation = self.simplePreviewView?.videoPreviewLayer.connection?.videoOrientation ?? .portrait if self.initialConfiguration.isRoundVideo { return mainDeviceContext.output.startRecording(mode: .roundVideo, orientation: DeviceModel.current.isIpad ? orientation : .portrait, additionalOutput: self.additionalDeviceContext?.output) @@ -790,6 +798,11 @@ public final class Camera { secondaryPreviewView.setSession(session.session, autoConnect: false) } + if #available(iOS 14.5, *), configuration.isRoundVideo { + AVCaptureDevice.centerStageControlMode = .app + AVCaptureDevice.isCenterStageEnabled = false + } + self.queue.async { let context = CameraContext(queue: self.queue, session: session, configuration: configuration, metrics: self.metrics, previewView: previewView, secondaryPreviewView: secondaryPreviewView) self.contextRef = Unmanaged.passRetained(context) @@ -803,6 +816,10 @@ public final class Camera { self.queue.async { contextRef?.release() } + + if #available(iOS 14.5, *) { + AVCaptureDevice.centerStageControlMode = .user + } } public func startCapture() { diff --git a/submodules/Camera/Sources/CameraDevice.swift b/submodules/Camera/Sources/CameraDevice.swift index e07d634d41..c1d3eefbc6 100644 --- a/submodules/Camera/Sources/CameraDevice.swift +++ b/submodules/Camera/Sources/CameraDevice.swift @@ -156,7 +156,7 @@ final class CameraDevice { if device.isLowLightBoostSupported { device.automaticallyEnablesLowLightBoostWhenAvailable = true } - + if device.isExposureModeSupported(.continuousAutoExposure) { device.exposureMode = .continuousAutoExposure } @@ -185,7 +185,7 @@ final class CameraDevice { private(set) var fps: Double = defaultFPS func setFps(_ fps: Double) { - guard let device = self.videoDevice, let targetFPS = device.actualFPS(Double(self.fps)) else { + guard let device = self.videoDevice, let targetFPS = device.actualFPS(Double(fps)) else { return } self.fps = targetFPS.fps @@ -305,7 +305,8 @@ final class CameraDevice { return } self.transaction(device) { device in - device.videoZoomFactor = max(device.neutralZoomFactor, min(10.0, device.neutralZoomFactor + zoomLevel)) + let target = device.neutralZoomFactor + zoomLevel + device.videoZoomFactor = self.clampedZoomFactor(target, for: device) } } @@ -314,7 +315,8 @@ final class CameraDevice { return } self.transaction(device) { device in - device.videoZoomFactor = max(1.0, min(10.0, device.videoZoomFactor * zoomDelta)) + let target = device.videoZoomFactor * zoomDelta + device.videoZoomFactor = self.clampedZoomFactor(target, for: device) } } @@ -323,7 +325,8 @@ final class CameraDevice { return } self.transaction(device) { device in - device.ramp(toVideoZoomFactor: zoomLevel, withRate: Float(rate)) + let target = self.clampedZoomFactor(zoomLevel, for: device) + device.ramp(toVideoZoomFactor: target, withRate: Float(rate)) } } @@ -332,7 +335,14 @@ final class CameraDevice { return } self.transaction(device) { device in - device.videoZoomFactor = neutral ? device.neutralZoomFactor : device.minAvailableVideoZoomFactor + let target = neutral ? device.neutralZoomFactor : device.minAvailableVideoZoomFactor + device.videoZoomFactor = self.clampedZoomFactor(target, for: device) } } + + private func clampedZoomFactor(_ value: CGFloat, for device: AVCaptureDevice) -> CGFloat { + let minimum = max(1.0, device.minAvailableVideoZoomFactor) + let maximum = max(minimum, device.maxAvailableVideoZoomFactor) + return min(maximum, max(minimum, value)) + } } diff --git a/submodules/Camera/Sources/CameraInput.swift b/submodules/Camera/Sources/CameraInput.swift index 29adeca209..f9114e2db0 100644 --- a/submodules/Camera/Sources/CameraInput.swift +++ b/submodules/Camera/Sources/CameraInput.swift @@ -15,11 +15,14 @@ class CameraInput { } func invalidate(for session: CameraSession, switchAudio: Bool = true) { - for input in session.session.inputs { - if !switchAudio && input === self.audioInput { - continue - } - session.session.removeInput(input) + if let videoInput = self.videoInput, session.session.inputs.contains(where: { $0 === videoInput }) { + session.session.removeInput(videoInput) + } + self.videoInput = nil + + if switchAudio, let audioInput = self.audioInput, session.session.inputs.contains(where: { $0 === audioInput }) { + session.session.removeInput(audioInput) + self.audioInput = nil } } diff --git a/submodules/Camera/Sources/CameraOutput.swift b/submodules/Camera/Sources/CameraOutput.swift index 4872e7635b..75dce41a14 100644 --- a/submodules/Camera/Sources/CameraOutput.swift +++ b/submodules/Camera/Sources/CameraOutput.swift @@ -192,9 +192,9 @@ final class CameraOutput: NSObject { } if #available(iOS 13.0, *), session.hasMultiCam { - if let device = device.videoDevice, let ports = input.videoInput?.ports(for: AVMediaType.video, sourceDeviceType: device.deviceType, sourceDevicePosition: device.position) { + if let device = device.videoDevice, let ports = input.videoInput?.ports(for: AVMediaType.video, sourceDeviceType: device.deviceType, sourceDevicePosition: device.position), let firstPort = ports.first { if let previewView { - let previewConnection = AVCaptureConnection(inputPort: ports.first!, videoPreviewLayer: previewView.videoPreviewLayer) + let previewConnection = AVCaptureConnection(inputPort: firstPort, videoPreviewLayer: previewView.videoPreviewLayer) if session.session.canAddConnection(previewConnection) { session.session.addConnection(previewConnection) self.previewConnection = previewConnection @@ -447,7 +447,7 @@ final class CameraOutput: NSObject { } return Signal { subscriber in - let timer = SwiftSignalKit.Timer(timeout: 0.1, repeat: true, completion: { [weak videoRecorder] in + let timer = SwiftSignalKit.Timer(timeout: 0.09, repeat: true, completion: { [weak videoRecorder] in let recordingData = CameraRecordingData(duration: videoRecorder?.duration ?? 0.0, filePath: outputFilePath) subscriber.putNext(recordingData) }, queue: Queue.mainQueue()) @@ -473,11 +473,7 @@ final class CameraOutput: NSObject { } var transitionImage: UIImage? { - var result: UIImage? - self.videoQueue.sync { - result = self.videoRecorder?.transitionImage - } - return result + return self.videoRecorder?.transitionImage } private weak var masterOutput: CameraOutput? @@ -705,7 +701,7 @@ extension CameraOutput: AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureA func captureOutput(_ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { if #available(iOS 13.0, *) { - Logger.shared.log("VideoRecorder", "Dropped sample buffer \(sampleBuffer.attachments)") + Logger.shared.log("Camera", "Dropped sample buffer \(sampleBuffer.attachments)") } } } diff --git a/submodules/Camera/Sources/VideoRecorder.swift b/submodules/Camera/Sources/VideoRecorder.swift index 5748938a75..e3772d446f 100644 --- a/submodules/Camera/Sources/VideoRecorder.swift +++ b/submodules/Camera/Sources/VideoRecorder.swift @@ -232,7 +232,9 @@ private final class VideoRecorderImpl { } else if self.orientation == .portraitUpsideDown { orientation = .left } - self.transitionImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: orientation) + Queue.mainQueue().async { + self.transitionImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: orientation) + } } else { self.savedTransitionImage = false } @@ -375,21 +377,21 @@ private final class VideoRecorderImpl { dispatchPrecondition(condition: .onQueue(self.queue)) let completion = self.completion if self.recordingStopSampleTime == .invalid { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } return } if let _ = self.error.with({ $0 }) { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } return } if !self.tryAppendingPendingAudioBuffers() { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } return @@ -398,21 +400,21 @@ private final class VideoRecorderImpl { if self.assetWriter.status == .writing { self.assetWriter.finishWriting { if let _ = self.assetWriter.error { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } } else { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(true, self.transitionImage, self.positionChangeTimestamps) } } } } else if let _ = self.assetWriter.error { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } } else { - DispatchQueue.main.async { + Queue.mainQueue().async { completion(false, nil, nil) } } diff --git a/submodules/LegacyComponents/Sources/TGCameraController.m b/submodules/LegacyComponents/Sources/TGCameraController.m index 3d9f789212..ee9724b20e 100644 --- a/submodules/LegacyComponents/Sources/TGCameraController.m +++ b/submodules/LegacyComponents/Sources/TGCameraController.m @@ -2751,6 +2751,8 @@ static CGPoint TGCameraControllerClampPointToScreenSize(__unused id self, __unus return CGRectMake(0, 82, screenSize.width, screenSize.height - 82 - 83); else if (widescreenWidth == 926.0f) return CGRectMake(0, 82, screenSize.width, screenSize.height - 82 - 83); + else if (widescreenWidth == 912.0f) + return CGRectMake(0, 82, screenSize.width, screenSize.height - 82 - 83); else if (widescreenWidth == 896.0f) return CGRectMake(0, 77, screenSize.width, screenSize.height - 77 - 83); else if (widescreenWidth == 874.0f) @@ -2784,6 +2786,8 @@ static CGPoint TGCameraControllerClampPointToScreenSize(__unused id self, __unus return CGRectMake(0, 136, screenSize.width, screenSize.height - 136 - 223); else if (widescreenWidth == 926.0f) return CGRectMake(0, 121, screenSize.width, screenSize.height - 121 - 234); + else if (widescreenWidth == 912.0f) + return CGRectMake(0, 136, screenSize.width, screenSize.height - 136 - 216); else if (widescreenWidth == 896.0f) return CGRectMake(0, 121, screenSize.width, screenSize.height - 121 - 223); else if (widescreenWidth == 874.0f) diff --git a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift index 8e663ee071..97e52bec43 100644 --- a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift +++ b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift @@ -575,14 +575,18 @@ private final class VideoMessageCameraScreenComponent: CombinedComponent { if !component.isPreviewing { if case .on = component.cameraState.flashMode, case .front = component.cameraState.position { + let frontFlashPosition = CGPoint(x: component.previewFrame.midX, y: component.previewFrame.midY) + let frontFlashSize = CGSize( + width: max(abs(frontFlashPosition.x), abs(context.availableSize.width - frontFlashPosition.x)) * 2.0, + height: max(abs(frontFlashPosition.y), abs(context.availableSize.height - frontFlashPosition.y)) * 2.0 + ) let frontFlash = frontFlash.update( - component: Image(image: state.image(.flashImage, theme: environment.theme), tintColor: component.cameraState.flashTint.color), - availableSize: context.availableSize, + component: Image(image: state.image(.flashImage, theme: environment.theme), tintColor: component.cameraState.flashTint.color, contentMode: .scaleAspectFill), + availableSize: frontFlashSize, transition: .easeInOut(duration: 0.2) ) - let frontFlashFrame = CGRect(origin: CGPoint(), size: context.availableSize) context.add(frontFlash - .position(frontFlashFrame.center) + .position(frontFlashPosition) .scale(1.5 - component.cameraState.flashTintSize * 0.5) .appear(.default(alpha: true)) .disappear(ComponentTransition.Disappear({ view, transition, completion in From 6eb201465096df117b29d9dc43c1c9805f3ca279 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 18:45:26 +0400 Subject: [PATCH 21/69] Fix web --- .../WebUI/Sources/WebAppController.swift | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index a72f287f7a..90202b3f4c 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1601,7 +1601,7 @@ public final class WebAppController: ViewController, AttachmentContainable { self.controller?._isPanGestureEnabled = isPanGestureEnabled } case "web_app_share_to_story": - if let json, let mediaUrl = json["media_url"] as? String { + if let json, let mediaUrl = json["media_url"] as? String, isAllowedBotMediaUrl(mediaUrl) { let text = json["text"] as? String let link = json["widget_link"] as? [String: Any] @@ -4309,3 +4309,116 @@ private struct WebAppConfiguration { } } } + +private func isAllowedBotMediaUrl(_ urlString: String) -> Bool { + guard let escaped = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: escaped) else { + return false + } + guard url.scheme?.lowercased() == "https" else { + return false + } + if url.user != nil || url.password != nil { + return false + } + guard var host = url.host?.lowercased(), !host.isEmpty else { + return false + } + if host.hasPrefix("[") && host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + + // Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). + // Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as + // decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the + // same string as octal 127.0.0.1 — the divergence is a loopback bypass. + if let v4Bytes = parseCanonicalIPv4(host) { + return isPublicIPv4(v4Bytes) + } + + // IPv6 only — host must contain ":" so we don't accidentally hand a + // numeric-looking hostname to inet_pton. + if host.contains(":") { + var v6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { + let bytes = withUnsafeBytes(of: &v6) { ptr -> [UInt8] in + return Array(ptr) + } + return isPublicIPv6(bytes) + } + return false + } + + // Strict DNS-name validation. Anything that doesn't look like a real + // FQDN is rejected — this catches non-canonical numeric IP forms + // (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like + // "0x7f.0.0.1", short forms like "127.1") that the OS resolver may + // still treat as 127.0.0.1 even when inet_pton would accept them as + // a different value or reject outright. + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + for label in labels { + guard !label.isEmpty, label.count <= 63 else { return false } + if label.first == "-" || label.last == "-" { return false } + for ch in label { + guard ch.isASCII else { return false } + if !(ch.isLetter || ch.isNumber || ch == "-") { return false } + } + } + guard let tld = labels.last, tld.count >= 2, tld.contains(where: { $0.isLetter }) else { + return false + } + + if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".local") { + return false + } + return true +} + +private func parseCanonicalIPv4(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(4) + for part in parts { + guard !part.isEmpty, part.count <= 3 else { return nil } + if part.count > 1 && part.first == "0" { return nil } // no leading zeros (octal-spoof) + guard part.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + guard let value = UInt8(part) else { return nil } // also caps at 255 + bytes.append(value) + } + return bytes +} + +private func isPublicIPv4(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 4 else { return false } + let a = bytes[0] + let b = bytes[1] + if a == 0 { return false } // 0.0.0.0/8 + if a == 10 { return false } // 10.0.0.0/8 + if a == 127 { return false } // 127.0.0.0/8 loopback + if a == 169 && b == 254 { return false } // 169.254.0.0/16 link-local + if a == 172 && (b & 0xf0) == 16 { return false } // 172.16.0.0/12 + if a == 192 && b == 168 { return false } // 192.168.0.0/16 + if a == 100 && (b & 0xc0) == 64 { return false } // 100.64.0.0/10 CGNAT + if a >= 224 { return false } // multicast + reserved + 255.255.255.255 + return true +} + +private func isPublicIPv6(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 16 else { return false } + if bytes.allSatisfy({ $0 == 0 }) { return false } // :: + let loopback: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1] + if bytes == loopback { return false } // ::1 + if bytes[0] == 0xff { return false } // ff00::/8 multicast + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { return false } // fe80::/10 link-local + if (bytes[0] & 0xfe) == 0xfc { return false } // fc00::/7 unique-local + let v4MappedPrefix: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0xff,0xff] + if Array(bytes.prefix(12)) == v4MappedPrefix { // ::ffff:a.b.c.d + return isPublicIPv4(Array(bytes.suffix(4))) + } + if Array(bytes.prefix(12)).allSatisfy({ $0 == 0 }) { // ::a.b.c.d (deprecated) + return isPublicIPv4(Array(bytes.suffix(4))) + } + return true +} From 06a0031bdd250ffbacabecc89fec5c17e7d31b85 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 18:22:42 +0200 Subject: [PATCH 22/69] Various improvements --- .../Messages/DeleteMessages.swift | 88 ++++++++++++++++++- .../Messages/TelegramEngineMessages.swift | 4 +- .../Sources/ChatControllerAdminBanUsers.swift | 3 +- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift index 5c3f072d76..84878e8508 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/DeleteMessages.swift @@ -76,9 +76,93 @@ func _internal_deleteAllMessagesWithForwardAuthor(transaction: Transaction, medi } } -func _internal_deleteAllReactionsWithAuthor(account: Account, peerId: PeerId, authorId: PeerId) -> Signal { +func _internal_deleteAllReactionsWithAuthor(account: Account, peerId: PeerId, authorId: PeerId, aroundMessageId: MessageId?) -> Signal { return account.postbox.transaction { transaction -> (Peer?, Peer?) in - return (transaction.getPeer(peerId), transaction.getPeer(authorId)) + let peer = transaction.getPeer(peerId) + let author = transaction.getPeer(authorId) + + if peer.flatMap(apiInputPeer) != nil && author.flatMap(apiInputPeer) != nil { + let anchor: HistoryViewInputAnchor + if let aroundMessageId, aroundMessageId.peerId == peerId { + anchor = .message(aroundMessageId) + } else { + anchor = .upperBound + } + let historyView = transaction.getMessagesHistoryViewState(input: .single(peerId: peerId, threadId: nil), ignoreMessagesInTimestampRange: nil, ignoreMessageIds: Set(), count: 50, clipHoles: true, anchor: anchor, namespaces: .just(Set([Namespaces.Message.Cloud]))) + for entry in historyView.entries { + transaction.updateMessage(entry.message.id, update: { currentMessage in + var attributes = currentMessage.attributes + var updated = false + + for i in 0 ..< attributes.count { + guard let attribute = attributes[i] as? ReactionsMessageAttribute else { + continue + } + + let removedRecentPeers = attribute.recentPeers.filter { $0.peerId == authorId } + var updatedTopPeers = attribute.topPeers + var removedStarsTopPeerCount: Int32 = 0 + for j in (0 ..< updatedTopPeers.count).reversed() { + if updatedTopPeers[j].peerId == authorId { + removedStarsTopPeerCount += updatedTopPeers[j].count + updatedTopPeers.remove(at: j) + } + } + + if removedRecentPeers.isEmpty && removedStarsTopPeerCount == 0 { + continue + } + + var updatedReactions = attribute.reactions + for removedRecentPeer in removedRecentPeers { + if let index = updatedReactions.firstIndex(where: { $0.value == removedRecentPeer.value }) { + if removedRecentPeer.value != .stars || removedStarsTopPeerCount == 0 { + updatedReactions[index].count -= 1 + } + if removedRecentPeer.isMy { + updatedReactions[index].chosenOrder = nil + } + } + } + if removedStarsTopPeerCount != 0, let index = updatedReactions.firstIndex(where: { $0.value == .stars }) { + updatedReactions[index].count -= removedStarsTopPeerCount + } + for j in (0 ..< updatedReactions.count).reversed() { + if updatedReactions[j].count <= 0 { + updatedReactions.remove(at: j) + } + } + + let updatedRecentPeers = attribute.recentPeers.filter { $0.peerId != authorId } + let updatedAttribute = ReactionsMessageAttribute(canViewList: attribute.canViewList, isTags: attribute.isTags, reactions: updatedReactions, recentPeers: updatedRecentPeers, topPeers: updatedTopPeers) + if updatedAttribute != attribute { + attributes[i] = updatedAttribute + updated = true + } + } + + if !updated { + return .skip + } + + var storeForwardInfo: StoreMessageForwardInfo? + if let forwardInfo = currentMessage.forwardInfo { + storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags) + } + + var tags = currentMessage.tags + if attributes.contains(where: { ($0 as? ReactionsMessageAttribute)?.hasUnseen == true }) { + tags.insert(.unseenReaction) + } else { + tags.remove(.unseenReaction) + } + + return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: attributes, media: currentMessage.media)) + }) + } + } + + return (peer, author) } |> mapToSignal { peer, author in guard let inputPeer = peer.flatMap(apiInputPeer), let inputAuthor = author.flatMap(apiInputPeer) else { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 81d51ae255..553cf4a3b9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -170,8 +170,8 @@ public extension TelegramEngine { |> ignoreValues } - public func deleteAllReactionsWithAuthor(peerId: EnginePeer.Id, authorId: EnginePeer.Id) -> Signal { - return _internal_deleteAllReactionsWithAuthor(account: self.account, peerId: peerId, authorId: authorId) + public func deleteAllReactionsWithAuthor(peerId: EnginePeer.Id, authorId: EnginePeer.Id, aroundMessageId: EngineMessage.Id? = nil) -> Signal { + return _internal_deleteAllReactionsWithAuthor(account: self.account, peerId: peerId, authorId: authorId, aroundMessageId: aroundMessageId) } public func deleteReaction(messageId: EngineMessage.Id, authorId: EnginePeer.Id) -> Signal { diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift index 8042d75db9..28e3fc0de9 100644 --- a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -85,8 +85,9 @@ extension ChatControllerImpl { let _ = self.context.engine.messages.clearAuthorHistory(peerId: messagesPeerId, memberId: authorId).startStandalone() } + let aroundMessageId = messageIds.count == 1 ? messageIds.first : nil for authorId in result.deleteAllReactionsFromPeers { - let _ = self.context.engine.messages.deleteAllReactionsWithAuthor(peerId: messagesPeerId, authorId: authorId).startStandalone() + let _ = self.context.engine.messages.deleteAllReactionsWithAuthor(peerId: messagesPeerId, authorId: authorId, aroundMessageId: aroundMessageId).startStandalone() } for authorId in result.reportSpamPeers { From 53ed837123d3598d55c805fc7ecc1a5fbafa82d9 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 21:22:51 +0400 Subject: [PATCH 23/69] Filter draft events --- .../Postbox/Sources/MessageHistoryView.swift | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index f9e4792913..c3f80e1ef8 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -1069,28 +1069,30 @@ final class MutableMessageHistoryView: MutablePostboxView { } } - switch self.peerIds { - case let .single(peerId, threadId): - let location = PeerAndThreadId(peerId: peerId, threadId: threadId) - if let typingDraftUpdate = transaction.updatedTypingDrafts[location] { - if let typingDraft = typingDraftUpdate.value, self.namespaces.contains(typingDraft.namespace) { - self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft) - } else { - self.typingDraft = nil + if self.tag == nil { + switch self.peerIds { + case let .single(peerId, threadId): + let location = PeerAndThreadId(peerId: peerId, threadId: threadId) + if let typingDraftUpdate = transaction.updatedTypingDrafts[location] { + if let typingDraft = typingDraftUpdate.value, self.namespaces.contains(typingDraft.namespace) { + self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft) + } else { + self.typingDraft = nil + } + hasChanges = true } - hasChanges = true + case .external: + break + case .associated: + break } - case .external: - break - case .associated: - break } return hasChanges } private func reloadTypingDraft(postbox: PostboxImpl) { - guard case let .single(peerId, threadId) = self.peerIds else { + guard case let .single(peerId, threadId) = self.peerIds, self.tag == nil else { self.typingDraft = nil return } From 301be02a89b23ebd641a7ef9fabecad53c0046b7 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 21:23:36 +0400 Subject: [PATCH 24/69] Update localization --- .../Telegram-iOS/en.lproj/Localizable.strings | 41 +++++++++++++++++++ .../Sources/VideoChatScreen.swift | 5 +-- .../Sources/TextProcessingScreen.swift | 28 ++++++------- ...extProcessingStyleSelectionComponent.swift | 3 +- ...tProcessingTranslateContentComponent.swift | 24 +++++------ .../Sources/TextStyleEditScreen.swift | 22 +++++----- .../Sources/ChatControllerContentData.swift | 3 +- .../TelegramUI/Sources/OpenChatMessage.swift | 3 +- .../TelegramUI/Sources/OpenResolvedUrl.swift | 3 +- 9 files changed, 80 insertions(+), 52 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index b7e3a23bc7..ba567e70b0 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16155,6 +16155,41 @@ Error: %8$@"; "TextProcessing.ResultBadge" = "Result"; "TextProcessing.Translate.LanguageStyle" = "%1$@ (%2$@)"; "TextProcessing.StyleTooltip" = "Select Style"; +"TextProcessing.AlertCreatorDeleteStyle.Title" = "Delete Style"; +"TextProcessing.AlertCreatorDeleteStyle.Text" = "Are you sure you want to delete this style? It will be removed for everyone who installed it."; +"TextProcessing.AlertDeleteStyle.Title" = "Delete Style"; +"TextProcessing.AlertDeleteStyle.Text" = "Are you sure you want to delete this style?"; +"TextProcessing.StyleMenu.Edit" = "Edit Style"; +"TextProcessing.StyleMenu.Share" = "Share Style"; +"TextProcessing.StyleMenu.Delete" = "Delete Style"; +"TextProcessing.StyleMenu.ButtonClose" = "Close"; +"TextProcessing.StyleMenu.ButtonAdd" = "Add Style"; +"TextProcessing.StylePreview.ExampleHeader" = "EXAMPLE"; +"TextProcessing.StylePreview.ExampleHeaderRefresh" = "ANOTHER EXAMPLE"; +"TextProcessing.StylePreview.Subtitle" = "Add this style to instantly\nrewrite your messages."; +"TextProcessing.StylePreview.Before" = "Before"; +"TextProcessing.StylePreview.After" = "After"; +"TextProcessing.AlertTooManyStyles.Title" = "Too Many Styles"; +"TextProcessing.AlertTooManyStyles.Text" = "Please delete some of your saved styles to create a new one."; +"TextProcessing.ToastStyleCreated.Title" = "%@ style created!"; +"TextProcessing.ToastStyleCreated.Text" = "Press and hold a style to edit or share the link."; +"TextProcessing.StyleList.Add" = "Add Style"; +"TextProcessing.StyleFooterAuthor" = "Style by [%@]()"; +"TextProcessing.StyleFooterUserCount_1" = "Used by 1 person"; +"TextProcessing.StyleFooterUserCount_any" = "Used by %d people"; +"TextProcessing.StyleFooterCreatedByFormat" = "%1$@. %2$@"; +"TextProcessing.StyleFooterCreatedBy" = "Created by [%@]()"; +"TextProcessing.StyleFooterCreatedBySimpleFormat" = "%@."; +"TextProcessing.EditStyle.NamePlaceholder" = "Style Name (for example, \"Pirate\")"; +"TextProcessing.EditStyle.TextPlaceholder" = "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure, the sea, and rum\")"; +"TextProcessing.EditStyle.TitleCreate" = "New Style"; +"TextProcessing.EditStyle.TitleEdit" = "Edit Style"; +"TextProcessing.EditStyle.ActionCreate" = "Create"; +"TextProcessing.EditStyle.ActionEdit" = "Save"; +"TextProcessing.EditStyle.Delete" = "Delete Style"; +"TextProcessing.EditStyle.AddLink" = "Add a link to my account"; +"TextProcessing.ToastStyleAdded.Title" = "Style Added"; +"TextProcessing.ToastStyleAdded.Text" = "Tap 'AI' → '%@' when typing your next long message."; "Bot.AlertCanNotCreateBots" = "%@ can't manage other bots."; @@ -16256,3 +16291,9 @@ Error: %8$@"; "Settings.ChatAutomation" = "Chat Automation"; "Settings.ChatAutomationInfo" = "Add a bot to reply to messages on your behalf."; "Settings.ChatAutomationOff" = "Off"; + +"Chat.SavedMessagesStatusViewAsChats" = "Tap to view as chats"; +"Chat.ToastVoiceMessageDeviceMuted" = "Device is muted."; + +"VideoChat.StatusPeerJoined" = "%@ joined"; +"VideoChat.StatusPeerLeft" = "%@ left"; diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index cfb47b39b3..7cc99459e5 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -1911,11 +1911,10 @@ final class VideoChatScreenComponent: Component { } } } else { - //TODO:localized if event.joined { - self.lastTitleEvent = "\(event.peer.compactDisplayTitle) joined" + self.lastTitleEvent = environment.strings.VideoChat_StatusPeerJoined(event.peer.compactDisplayTitle).string } else { - self.lastTitleEvent = "\(event.peer.compactDisplayTitle) left" + self.lastTitleEvent = environment.strings.VideoChat_StatusPeerLeft(event.peer.compactDisplayTitle).string } if !self.isUpdating { self.state?.updated(transition: .spring(duration: 0.4)) diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift index 2a17a4afbe..dc9d2a985c 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingScreen.swift @@ -310,8 +310,8 @@ final class TextProcessingContentComponent: Component { if style.isCreator { environment.controller()?.push(textAlertController( context: component.context, - title: "Delete Style", - text: "Are you sure you want to delete this style? It will be removed for everyone who installed it.", + title: environment.strings.TextProcessing_AlertCreatorDeleteStyle_Title, + text: environment.strings.TextProcessing_AlertCreatorDeleteStyle_Text, actions: [ TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in @@ -326,8 +326,8 @@ final class TextProcessingContentComponent: Component { } else { environment.controller()?.push(textAlertController( context: component.context, - title: "Delete Style", - text: "Are you sure you want to delete this style?", + title: environment.strings.TextProcessing_AlertDeleteStyle_Title, + text: environment.strings.TextProcessing_AlertDeleteStyle_Text, actions: [ TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in @@ -354,7 +354,7 @@ final class TextProcessingContentComponent: Component { var items: [ContextMenuItem] = [] if style.isAuthor { items.append(.action(ContextMenuActionItem( - text: "Edit Style", + text: environment.strings.TextProcessing_StyleMenu_Edit, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, @@ -369,7 +369,7 @@ final class TextProcessingContentComponent: Component { )) } items.append(.action(ContextMenuActionItem( - text: "Share Style", //TODO:localize + text: environment.strings.TextProcessing_StyleMenu_Share, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Forward"), color: theme.contextMenu.primaryColor) }, @@ -383,7 +383,7 @@ final class TextProcessingContentComponent: Component { }) )) items.append(.action(ContextMenuActionItem( - text: "Delete Style", + text: environment.strings.TextProcessing_StyleMenu_Delete, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) @@ -576,7 +576,7 @@ final class TextProcessingContentComponent: Component { let descriptionSize = previewDescription.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "Add this style to instantly\nrewrite your messages.", font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor)), + text: .plain(NSAttributedString(string: environment.strings.TextProcessing_StylePreview_Subtitle, font: Font.regular(15.0), textColor: environment.theme.list.itemPrimaryTextColor)), horizontalAlignment: .center, maximumNumberOfLines: 0, lineSpacing: 0.12 @@ -805,7 +805,6 @@ final class TextProcessingContentComponent: Component { if component.styles.filter({ $0.isAuthor }).count >= maxStyles { if !hasPremium { - //TODO:localize let context = component.context var replaceImpl: ((ViewController) -> Void)? let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .aiTools, forceDark: false, action: { @@ -824,8 +823,8 @@ final class TextProcessingContentComponent: Component { } else { environment.controller()?.push(textAlertController( context: component.context, - title: "Too Many Styles", - text: "Please delete some of your saved styles to create a new one.", + title: environment.strings.TextProcessing_AlertTooManyStyles_Title, + text: environment.strings.TextProcessing_AlertTooManyStyles_Text, actions: [ TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {}), ] @@ -1435,8 +1434,7 @@ private final class TextProcessingSheetComponent: Component { dismiss(true) } case let .preview(style, _, _, isAlreadyAdded, added): - //TODO:localize - actionButtonTitle = isAlreadyAdded ? "Done" : "Add Style" + actionButtonTitle = isAlreadyAdded ? environmentValue.strings.TextProcessing_StyleMenu_ButtonClose : environmentValue.strings.TextProcessing_StyleMenu_ButtonAdd isMainActionEnabled = !self.isPerformingMainAction performMainAction = { [weak self] in guard let self, let component = self.component else { @@ -1797,10 +1795,10 @@ private final class TextProcessingSheetComponent: Component { )), content: AnyComponent(VStack([ AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "\(styleCreatedToastData.style.title) style created!", font: Font.semibold(14.0), textColor: .white)), + text: .plain(NSAttributedString(string: environmentValue.strings.TextProcessing_ToastStyleCreated_Title(styleCreatedToastData.style.title).string, font: Font.semibold(14.0), textColor: .white)), ))), AnyComponentWithIdentity(id: 1, component: AnyComponent(MultilineTextComponent( - text: .markdown(text: "Press and hold a style to edit or share the link.", attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in nil })), + text: .markdown(text: environmentValue.strings.TextProcessing_ToastStyleCreated_Text, attributes: MarkdownAttributes(body: body, bold: bold, link: body, linkAttribute: { _ in nil })), maximumNumberOfLines: 0 ))) ], alignment: .left, spacing: 6.0)), diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift index d7529e9a3b..e265719ceb 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingStyleSelectionComponent.swift @@ -638,11 +638,10 @@ private final class CreateItemComponent: Component { containerSize: CGSize(width: 100.0, height: 100.0) ) - //TODO:localize let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "Add Style", font: Font.medium(10.0), textColor: iconTintColor)) + text: .plain(NSAttributedString(string: component.strings.TextProcessing_StyleList_Add, font: Font.medium(10.0), textColor: iconTintColor)) )), environment: {}, containerSize: CGSize(width: availableSize.width, height: 100.0) diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift index 7f5bc81b5d..186e69904f 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingTranslateContentComponent.swift @@ -382,9 +382,8 @@ final class TextProcessingTranslateContentComponent: Component { } toTitle = "" case .preview: - //TODO:localize - fromFormat = "Before" - toFormat = "After" + fromFormat = component.strings.TextProcessing_StylePreview_Before + toFormat = component.strings.TextProcessing_StylePreview_After toTitle = "" } @@ -407,14 +406,12 @@ final class TextProcessingTranslateContentComponent: Component { } if case .stylize = component.mode { - //TODO:localize if case .style = component.externalState.style, let style = component.styles.first(where: { $0.id == component.externalState.style }), let authorPeer = style.authorPeer { let footerText: String - //TODO:localize if let addressName = authorPeer.addressName { - footerText = "Style by [@\(addressName)](\(authorPeer.id.toInt64()))" + footerText = component.strings.TextProcessing_StyleFooterAuthor("@" + addressName).string } else { - footerText = "Style by [\(authorPeer.displayTitle(strings: component.strings, displayOrder: .firstLast))](\(authorPeer.id.toInt64()))" + footerText = component.strings.TextProcessing_StyleFooterAuthor(authorPeer.displayTitle(strings: component.strings, displayOrder: .firstLast)).string } component.externalState.sectionFooter = AnyComponentWithIdentity(id: "style_by_\(authorPeer.id.toInt64())", component: AnyComponent(MultilineTextComponent( text: .markdown(text: footerText, attributes: MarkdownAttributes( @@ -448,7 +445,7 @@ final class TextProcessingTranslateContentComponent: Component { } else if case let .preview(_, _, authorPeer, userCount, _) = component.mode { component.externalState.sectionHeader = AnyComponentWithIdentity(id: "preview", component: AnyComponent(HStack([ AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( - text: .markdown(text: "EXAMPLE", attributes: MarkdownAttributes( + text: .markdown(text: component.strings.TextProcessing_StylePreview_ExampleHeader, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(13.0), textColor: component.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(13.0), textColor: component.theme.list.freeTextColor), link: MarkdownAttributeSet(font: Font.regular(13.0), textColor: component.theme.list.itemAccentColor), @@ -464,7 +461,7 @@ final class TextProcessingTranslateContentComponent: Component { tintColor: component.theme.list.itemAccentColor ))), AnyComponentWithIdentity(id: 1, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "ANOTHER EXAMPLE", font: Font.regular(13.0), textColor: component.theme.list.itemAccentColor)) + text: .plain(NSAttributedString(string: component.strings.TextProcessing_StylePreview_ExampleHeaderRefresh, font: Font.regular(13.0), textColor: component.theme.list.itemAccentColor)) ))) ], spacing: 2.0)), action: { [weak self] in @@ -478,18 +475,17 @@ final class TextProcessingTranslateContentComponent: Component { ))), ], spacing: 8.0, alignment: .alternatingLeftRight))) - let userCountString = userCount == 1 ? "Used by 1 person" : "Used by \(userCount) people" + let userCountString = component.strings.TextProcessing_StyleFooterUserCount(Int32(userCount)) let footerText: String - //TODO:localize if let authorPeer { if let addressName = authorPeer.addressName { - footerText = "\(userCountString). Created by [@\(addressName)](\(authorPeer.id.toInt64()))" + footerText = component.strings.TextProcessing_StyleFooterCreatedByFormat(userCountString, component.strings.TextProcessing_StyleFooterCreatedBy("@" + addressName).string).string } else { - footerText = "\(userCountString). Created by [\(authorPeer.displayTitle(strings: component.strings, displayOrder: .firstLast))](\(authorPeer.id.toInt64()))" + footerText = component.strings.TextProcessing_StyleFooterCreatedByFormat(userCountString, component.strings.TextProcessing_StyleFooterCreatedBy(authorPeer.displayTitle(strings: component.strings, displayOrder: .firstLast)).string).string } } else { - footerText = "\(userCountString)." + footerText = component.strings.TextProcessing_StyleFooterCreatedBySimpleFormat(userCountString).string } component.externalState.sectionFooter = AnyComponentWithIdentity(id: "style_by_\(authorPeer?.id.toInt64() ?? 0)", component: AnyComponent(MultilineTextComponent( text: .markdown(text: footerText, attributes: MarkdownAttributes( diff --git a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift index 88e0e2fa9d..ae218bcc95 100644 --- a/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift +++ b/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift @@ -335,7 +335,6 @@ final class TextStyleEditContentComponent: Component { contentHeight += iconBackgroundSize.height + iconSpacing - //TODO:localize var titleSectionItems: [AnyComponentWithIdentity] = [] titleSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: component.externalState.titleInputState, @@ -347,7 +346,7 @@ final class TextStyleEditContentComponent: Component { resetText: resetTitle.flatMap { resetTitle in return ListMultilineTextFieldItemComponent.ResetText(value: resetTitle) }, - placeholder: "Style Name (for example, \"Pirate\")", + placeholder: environment.strings.TextProcessing_EditStyle_NamePlaceholder, autocapitalizationType: .sentences, autocorrectionType: .default, characterLimit: 12, @@ -379,7 +378,6 @@ final class TextStyleEditContentComponent: Component { contentHeight += titleSectionSize.height contentHeight += sectionSpacing - //TODO:localize var textSectionItems: [AnyComponentWithIdentity] = [] textSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListMultilineTextFieldItemComponent( externalState: component.externalState.textInputState, @@ -391,7 +389,7 @@ final class TextStyleEditContentComponent: Component { resetText: resetText.flatMap { resetText in return ListMultilineTextFieldItemComponent.ResetText(value: resetText) }, - placeholder: "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure, the sea, and rum\")", + placeholder: environment.strings.TextProcessing_EditStyle_TextPlaceholder, placeholderDefinesMinHeight: true, autocapitalizationType: .sentences, autocorrectionType: .default, @@ -439,7 +437,7 @@ final class TextStyleEditContentComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Delete Style", + string: environment.strings.TextProcessing_EditStyle_Delete, font: Font.regular(17.0), textColor: environment.theme.list.itemDestructiveColor )), @@ -453,8 +451,8 @@ final class TextStyleEditContentComponent: Component { } environment.controller()?.push(textAlertController( context: component.context, - title: "Delete Style", - text: "Are you sure you want to delete this style? It will be removed for everyone who installed it.", + title: environment.strings.TextProcessing_AlertCreatorDeleteStyle_Title, + text: environment.strings.TextProcessing_AlertCreatorDeleteStyle_Text, actions: [ TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .destructiveAction, title: environment.strings.Common_Delete, action: { [weak self] in @@ -503,7 +501,7 @@ final class TextStyleEditContentComponent: Component { selected: component.externalState.isLinkToProfileEnabled ))), AnyComponentWithIdentity(id: AnyHashable(1), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "Add a link to my account", font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor)) + text: .plain(NSAttributedString(string: environment.strings.TextProcessing_EditStyle_AddLink, font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor)) ))) ], spacing: 10.0)), effectAlignment: .center, @@ -779,11 +777,11 @@ private final class TextStyleEditSheetComponent: Component { let titleString: String switch component.mode { case .create: - titleString = "New Style" - actionButtonTitle = "Create" + titleString = environmentValue.strings.TextProcessing_EditStyle_TitleCreate + actionButtonTitle = environmentValue.strings.TextProcessing_EditStyle_ActionCreate case .edit: - titleString = "Edit Style" - actionButtonTitle = "Save" + titleString = environmentValue.strings.TextProcessing_EditStyle_TitleEdit + actionButtonTitle = environmentValue.strings.TextProcessing_EditStyle_ActionEdit } let sheetSize = self.sheet.update( diff --git a/submodules/TelegramUI/Sources/ChatControllerContentData.swift b/submodules/TelegramUI/Sources/ChatControllerContentData.swift index fc9d8e5b4d..fcc7e6e66f 100644 --- a/submodules/TelegramUI/Sources/ChatControllerContentData.swift +++ b/submodules/TelegramUI/Sources/ChatControllerContentData.swift @@ -609,8 +609,7 @@ extension ChatControllerImpl { } else { var customSubtitle: String? if savedMessagesChatsTip { - //TODO:localize - customSubtitle = "Tap to view as chats" + customSubtitle = strings.Chat_SavedMessagesStatusViewAsChats } strongSelf.state.chatTitleContent = .peer(peerView: ChatTitleContent.PeerData(peerView: peerView), customTitle: nil, customSubtitle: customSubtitle, onlineMemberCount: onlineMemberCount, isScheduledMessages: isScheduledMessages, isMuted: nil, customMessageCount: nil, hidePeerStatus: false, isEnabled: hasPeerInfo) diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index addf21cee3..6d916852c5 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -330,8 +330,7 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { } |> take(1) |> timeout(1.0, queue: .mainQueue(), alternate: .single(false)) |> deliverOnMainQueue).startStandalone(next: { value in if value { let presentationData = params.context.sharedContext.currentPresentationData.with { $0 } - //TODO:localize - let toastController = UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: "Device is muted.", timeout: 4.0, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in + let toastController = UndoOverlayController(presentationData: presentationData, content: .info(title: nil, text: presentationData.strings.Chat_ToastVoiceMessageDeviceMuted, timeout: 4.0, customUndoText: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return true }) params.present(toastController, nil, .current) diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index d77a7eb022..d448a26b79 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -2054,8 +2054,7 @@ func openResolvedUrlImpl( guard let emojiFileId = style.emojiFileId, let file = await context.engine.stickers.resolveInlineStickers(fileIds: [emojiFileId]).get().first?.value else { return } - //TODO:localize - present(UndoOverlayController(presentationData: presentationData, content: .customEmoji(context: context, file: file, loop: false, title: "Style Added", text: "Tap 'AI' → '\(style.title)' when typing your next long message.", undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in + present(UndoOverlayController(presentationData: presentationData, content: .customEmoji(context: context, file: file, loop: false, title: presentationData.strings.TextProcessing_ToastStyleAdded_Title, text: presentationData.strings.TextProcessing_ToastStyleAdded_Text(style.title).string, undoText: nil, customAction: nil), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return true }), nil) } From 14bbdca0508893cfa67895b000400cee7409511c Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 20:24:29 +0200 Subject: [PATCH 25/69] Various improvements --- .../Telegram-iOS/en.lproj/Localizable.strings | 8 ++ .../ReactionListContextMenuContent/BUILD | 1 + .../ReactionListContextMenuContent.swift | 113 ++++++++++++++++-- .../Settings/ChatbotSetupScreen/BUILD | 1 + .../ChatbotSearchResultItemComponent.swift | 6 +- .../Sources/ChatbotSetupScreen.swift | 24 +++- ...ChatControllerOpenMessageContextMenu.swift | 16 ++- .../Sources/Chat/ChatControllerToasts.swift | 11 ++ .../TelegramUI/Sources/ChatController.swift | 5 +- .../Sources/ChatControllerAdminBanUsers.swift | 2 +- ...rollerOpenMessageReactionContextMenu.swift | 4 +- 11 files changed, 172 insertions(+), 19 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index b7e3a23bc7..ae9ce796bf 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16256,3 +16256,11 @@ Error: %8$@"; "Settings.ChatAutomation" = "Chat Automation"; "Settings.ChatAutomationInfo" = "Add a bot to reply to messages on your behalf."; "Settings.ChatAutomationOff" = "Off"; + +"Chat.SendReactionRestricted" = "You cannot send reactions in this chat."; + +"ChatbotSetup.BotInstalled" = "%@ now manages your account."; + +"ChatbotSetup.SetupNotCompleted.Title" = "No Bot Added"; +"ChatbotSetup.SetupNotCompleted.Text" = "You haven’t added a bot to manage your account. Leave anyway?"; +"ChatbotSetup.SetupNotCompleted.Leave" = "Leave"; diff --git a/submodules/Components/ReactionListContextMenuContent/BUILD b/submodules/Components/ReactionListContextMenuContent/BUILD index 4ee75ad2d2..d91f85c4ab 100644 --- a/submodules/Components/ReactionListContextMenuContent/BUILD +++ b/submodules/Components/ReactionListContextMenuContent/BUILD @@ -20,6 +20,7 @@ swift_library( "//submodules/AnimatedAvatarSetNode:AnimatedAvatarSetNode", "//submodules/ContextUI:ContextUI", "//submodules/AvatarNode:AvatarNode", + "//submodules/Components/MultilineTextComponent:MultilineTextComponent", "//submodules/Components/ReactionImageComponent:ReactionImageComponent", "//submodules/TelegramUI/Components/AnimationCache:AnimationCache", "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", diff --git a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift index 5ae4f9ddcc..980ca8e473 100644 --- a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift +++ b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift @@ -2,6 +2,7 @@ import Foundation import AsyncDisplayKit import Display import ComponentFlow +import MultilineTextComponent import SwiftSignalKit import TelegramCore import AccountContext @@ -373,12 +374,13 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent let availableReactions: AvailableReactions? let animationCache: AnimationCache let animationRenderer: MultiAnimationRenderer + private let highlightBackgroundView: UIView let avatarNode: AvatarNode let titleLabelNode: ImmediateTextNode let textLabelNode: ImmediateTextNode let readIconView: UIImageView var credibilityIconView: ComponentView? - + private var reactionLayer: InlineStickerItemLayer? private var iconFrame: CGRect? private var file: TelegramMediaFile? @@ -419,16 +421,35 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.readIconView = UIImageView(image: readIconImage) + self.highlightBackgroundView = UIView() + self.highlightBackgroundView.alpha = 0.0 + self.highlightBackgroundView.isUserInteractionEnabled = false + super.init() - + self.isAccessibilityElement = true - + + self.view.insertSubview(self.highlightBackgroundView, at: 0) + self.addSubnode(self.avatarNode) self.addSubnode(self.titleLabelNode) self.addSubnode(self.textLabelNode) self.view.addSubview(self.readIconView) self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + self.highligthedChanged = { [weak self] highlighted in + guard let self else { + return + } + if highlighted { + self.highlightBackgroundView.layer.removeAnimation(forKey: "opacity") + self.highlightBackgroundView.alpha = 0.1 + } else { + let currentAlpha = self.highlightBackgroundView.alpha + self.highlightBackgroundView.alpha = 0.0 + self.highlightBackgroundView.layer.animateAlpha(from: currentAlpha, to: 0.0, duration: 0.3) + } + } let longTapRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longTapGesture(_:))) longTapRecognizer.isEnabled = false @@ -537,7 +558,12 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent let sideInset: CGFloat = 16.0 let reaction: MessageReaction.Reaction? = item.reaction - + + self.highlightBackgroundView.backgroundColor = presentationData.theme.overallDarkAppearance ? UIColor.white : UIColor.black + self.highlightBackgroundView.setMonochromaticEffect(tintColor: self.highlightBackgroundView.backgroundColor) + self.highlightBackgroundView.frame = CGRect(origin: CGPoint(x: 10.0, y: 5.0), size: CGSize(width: max(0.0, size.width - 20.0), height: size.height - 10.0)) + self.highlightBackgroundView.layer.cornerRadius = self.highlightBackgroundView.frame.height * 0.5 + if self.displayReactionIcon, reaction != self.item?.reaction { if let reaction = reaction { switch reaction { @@ -810,6 +836,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent private var hasMore: Bool = false private let scrollNode: ASScrollNode + private let separatorNode: ASDisplayNode private var ignoreScrolling: Bool = false private var animateIn: Bool = false private var bottomScrollInset: CGFloat = 0.0 @@ -826,7 +853,9 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent private var placeholderItemImage: UIImage? private var placeholderLayers: [Int: SimpleLayer] = [:] - + + private let deleteReactionInfoText: ComponentView + init( context: AccountContext, displayReadTimestamps: Bool, @@ -858,6 +887,9 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.state = ItemsState(listState: EngineMessageReactionListContext.State(message: message, readStats: readStats, reaction: reaction), readStats: readStats) self.scrollNode = ASScrollNode() + self.separatorNode = ASDisplayNode() + self.deleteReactionInfoText = ComponentView() + self.scrollNode.canCancelAllTouchesInViews = true self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.showsVerticalScrollIndicator = false @@ -871,6 +903,10 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.addSubnode(self.scrollNode) self.scrollNode.view.delegate = self.wrappedScrollViewDelegate + self.separatorNode.isHidden = true + self.separatorNode.isUserInteractionEnabled = false + self.scrollNode.addSubnode(self.separatorNode) + self.clipsToBounds = true self.stateDisposable = (self.listContext.state @@ -965,7 +1001,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent } itemNode.update(size: itemFrame.size, presentationData: presentationData, item: item, isLast: self.state.item(at: index + 1) == nil, syncronousLoad: syncronousLoad) - if let deleteReaction = self.deleteReaction, let reaction = item.reaction { + if let deleteReaction = self.deleteReaction, let reaction = item.reaction, item.peer.id != self.context.account.peerId { let peer = item.peer itemNode.longTapAction = { deleteReaction(peer, reaction) @@ -1079,7 +1115,70 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent } self.presentationData = presentationData - let size = CGSize(width: constrainedSize.width, height: topInset + CGFloat(self.state.totalCount) * itemHeight + topInset) + let baseContentHeight = topInset + CGFloat(self.state.totalCount) * itemHeight + topInset + var footerHeight: CGFloat = 0.0 + let displayDeleteReactionInfoFooter: Bool + if self.deleteReaction != nil { + displayDeleteReactionInfoFooter = self.state.mergedItems.contains(where: { item in + return item.reaction != nil && item.peer.id != self.context.account.peerId + }) + } else { + displayDeleteReactionInfoFooter = false + } + + if displayDeleteReactionInfoFooter { + let separatorHeight: CGFloat = 20.0 + let horizontalInset: CGFloat = 18.0 + let textTopInset: CGFloat = 5.0 + let textBottomInset: CGFloat = 18.0 + let textFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 13.0 / 17.0)) + + self.separatorNode.isHidden = false + self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor + + var animateIn = false + var footerTransition = transition + if self.deleteReactionInfoText.view?.superview == nil, footerTransition.isAnimated { + footerTransition = .immediate + animateIn = true + } + footerTransition.updateFrame(node: self.separatorNode, frame: CGRect( + origin: CGPoint(x: horizontalInset, y: baseContentHeight + floorToScreenPixels((separatorHeight - UIScreenPixel) * 0.5)), + size: CGSize(width: max(0.0, constrainedSize.width - horizontalInset * 2.0), height: 1.0) + )) + + let textSize = self.deleteReactionInfoText.update( + transition: ComponentTransition(footerTransition), + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: presentationData.strings.Chat_DeleteReactionInfo, + font: textFont, + textColor: presentationData.theme.contextMenu.primaryColor + )), + maximumNumberOfLines: 0 + )), + environment: {}, + containerSize: CGSize(width: max(0.0, constrainedSize.width - horizontalInset * 2.0), height: .greatestFiniteMagnitude) + ) + if let textView = self.deleteReactionInfoText.view { + if textView.superview == nil { + self.scrollNode.view.addSubview(textView) + textView.isUserInteractionEnabled = false + } + footerTransition.updateFrame(view: textView, frame: CGRect(origin: CGPoint(x: horizontalInset, y: baseContentHeight + separatorHeight + textTopInset), size: textSize)) + + if animateIn { + self.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + textView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + + footerHeight = separatorHeight + textTopInset + textSize.height + textBottomInset + } else { + self.separatorNode.isHidden = true + self.deleteReactionInfoText.view?.removeFromSuperview() + } + let size = CGSize(width: constrainedSize.width, height: baseContentHeight + footerHeight) let containerSize = CGSize(width: size.width, height: min(constrainedSize.height, size.height)) self.currentSize = containerSize diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD index 210ac682a5..ed399a9fc6 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/BUILD @@ -36,6 +36,7 @@ swift_library( "//submodules/TelegramUI/Components/Stories/PeerListItemComponent", "//submodules/TelegramUI/Components/AlertComponent", "//submodules/ShimmerEffect", + "//submodules/UndoUI", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift index bbe37bf01b..ecda61297b 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSearchResultItemComponent.swift @@ -208,7 +208,11 @@ final class ChatbotSearchResultItemComponent: Component { case let .found(peer, _): isTextVisible = true titleValue = peer.displayTitle(strings: component.strings, displayOrder: .firstLast) - subtitleValue = component.strings.Bot_GenericBotStatus + if let addressName = peer.addressName { + subtitleValue = "@\(addressName)" + } else { + subtitleValue = component.strings.Bot_GenericBotStatus + } } let titleSize = self.titleLabel.update( diff --git a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift index 8020a1a2f4..c387c29fa6 100644 --- a/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift +++ b/submodules/TelegramUI/Components/Settings/ChatbotSetupScreen/Sources/ChatbotSetupScreen.swift @@ -25,6 +25,7 @@ import Markdown import PeerListItemComponent import AvatarNode import AlertComponent +import UndoUI private let checkIcon: UIImage = { return generateImage(CGSize(width: 12.0, height: 10.0), rotatedContext: { size, context in @@ -216,10 +217,21 @@ final class ChatbotSetupScreenComponent: Component { } func attemptNavigation(complete: @escaping () -> Void) -> Bool { - guard let component = self.component else { + guard let component = self.component, let environemnt = self.environment else { return true } + if let botResolutionState = self.botResolutionState, case let .found(_, isInstalled) = botResolutionState.state, !isInstalled { + let alertController = textAlertController(context: component.context, title: environemnt.strings.ChatbotSetup_SetupNotCompleted_Title, text: environemnt.strings.ChatbotSetup_SetupNotCompleted_Text, actions: [ + TextAlertAction(type: .defaultAction, title: environemnt.strings.Common_Cancel, action: {}), + TextAlertAction(type: .genericAction, title: environemnt.strings.ChatbotSetup_SetupNotCompleted_Leave, action: { + complete() + }) + ]) + environemnt.controller()?.present(alertController, in: .window(.root)) + return false + } + var mappedCategories: TelegramBusinessRecipients.Categories = [] if self.additionalPeerList.categories.contains(.existingChats) { mappedCategories.insert(.existingChats) @@ -803,7 +815,7 @@ final class ChatbotSetupScreenComponent: Component { strings: environment.strings, content: mappedContent, installAction: { [weak self] in - guard let self else { + guard let self, let component = self.component, let environment = self.environment, let controller = self.environment?.controller() else { return } self.endEditing(true) @@ -820,6 +832,14 @@ final class ChatbotSetupScreenComponent: Component { }) ]), in: .window(.root)) } + controller.present(UndoOverlayController( + presentationData: presentationData, + content: .invitedToVoiceChat(context: component.context, peer: peer, title: nil, text: environment.strings.ChatbotSetup_BotInstalled(peer.compactDisplayTitle).string, action: nil, duration: 2.0), + elevatedLayout: false, + position: .bottom, + animateInAsReplacement: false, + action: { _ in return true } + ), in: .current) } }, removeAction: { [weak self] in diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift index a1201fb48a..08750090f1 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenMessageContextMenu.swift @@ -374,7 +374,13 @@ extension ChatControllerImpl { if case .stars = chosenUpdatedReaction.reaction { if !canSendReactionsToChat(self.presentationInterfaceState) { - controller?.dismiss(completion: {}) + if let controller { + controller.dismiss(completion: { [weak self] in + self?.displaySendReactionRestrictedToast() + }) + } else { + self.displaySendReactionRestrictedToast() + } return } @@ -514,7 +520,13 @@ extension ChatControllerImpl { } if removedReaction == nil && !canSendReactionsToChat(self.presentationInterfaceState) { - controller?.dismiss(completion: {}) + if let controller { + controller.dismiss(completion: { [weak self] in + self?.displaySendReactionRestrictedToast() + }) + } else { + self.displaySendReactionRestrictedToast() + } return } diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift index f2ddc3926b..754f6996c2 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift @@ -10,6 +10,17 @@ import AccountContext import ChatControllerInteraction extension ChatControllerImpl { + func displaySendReactionRestrictedToast() { + self.controllerInteraction?.presentControllerInCurrent(UndoOverlayController( + presentationData: self.presentationData, + content: .banned(text: self.presentationData.strings.Chat_SendReactionRestricted), + elevatedLayout: false, + position: .bottom, + animateInAsReplacement: false, + action: { _ in return true } + ), nil) + } + func displayPostedScheduledMessagesToast(ids: [EngineMessage.Id]) { let timestamp = CFAbsoluteTimeGetCurrent() if self.lastPostedScheduledMessagesToastTimestamp + 0.4 >= timestamp { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index fa5f539165..f845e2024d 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -1682,9 +1682,6 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case .default = reaction, strongSelf.chatLocation.peerId == strongSelf.context.account.peerId { return } - if case .default = reaction, !canSendReactionsToChat(strongSelf.presentationInterfaceState) { - return - } if case let .customChatContents(customChatContents) = strongSelf.presentationInterfaceState.subject { if case let .hashTagSearch(publicPosts) = customChatContents.kind, publicPosts { return @@ -1798,6 +1795,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if case .stars = chosenReaction { if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { + strongSelf.displaySendReactionRestrictedToast() return } @@ -1968,6 +1966,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if removedReaction == nil { if !canSendReactionsToChat(strongSelf.presentationInterfaceState) { + strongSelf.displaySendReactionRestrictedToast() return } diff --git a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift index 28e3fc0de9..958341234f 100644 --- a/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift +++ b/submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift @@ -263,7 +263,7 @@ extension ChatControllerImpl { } public func presentReactionDeletionOptions(author: Peer, messageId: MessageId) { - guard self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + guard self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel, author.id != self.context.account.peerId else { return } let _ = (self.context.sharedContext.chatAvailableMessageActions( diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift index 96ce489741..2be170fc78 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift @@ -321,9 +321,7 @@ extension ChatControllerImpl { let presentationContext = self.controllerInteraction?.presentationContext let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) - if canDeleteReactions && canViewReactions { - items.tip = .deleteReaction - } else if !packReferences.isEmpty && !premiumConfiguration.isPremiumDisabled { + if !packReferences.isEmpty && !premiumConfiguration.isPremiumDisabled { items.tip = .animatedEmoji(text: nil, arguments: nil, file: nil, action: nil) if packReferences.count > 1 { From fd75bdb3561380f4eed5d91bdf067f2b99942140 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 21:15:55 +0200 Subject: [PATCH 26/69] Fix --- Telegram/Telegram-iOS/en.lproj/Localizable.strings | 1 + .../Sources/ChatRecentActionsHistoryTransition.swift | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 964a7b2ad0..117c2807bb 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -2707,6 +2707,7 @@ Unused sets are archived when you add more."; "Channel.AdminLog.AddMembers" = "Add Members"; "Channel.AdminLog.SendPolls" = "Send Polls"; "Channel.AdminLog.ManageTopics" = "Manage Topics"; +"Channel.AdminLog.SendReactions" = "Send Reactions"; "Channel.AdminLog.CanChangeInfo" = "Change Info"; "Channel.AdminLog.CanSendMessages" = "Post Messages"; diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift index bbf9c2660d..929b524704 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift @@ -776,6 +776,7 @@ struct ChatRecentActionsEntry: Comparable, Identifiable { (.banSendStickers, self.presentationData.strings.Channel_AdminLog_BanSendStickersAndGifs), (.banEmbedLinks, self.presentationData.strings.Channel_AdminLog_BanEmbedLinks), (.banSendPolls, self.presentationData.strings.Channel_AdminLog_SendPolls), + (.banSendReactions, self.presentationData.strings.Channel_AdminLog_SendReactions), (.banAddMembers, self.presentationData.strings.Channel_AdminLog_AddMembers), (.banEditRank, self.presentationData.strings.Channel_AdminLog_EditRankOwn), (.banPinMessages, self.presentationData.strings.Channel_AdminLog_PinMessages), @@ -1141,6 +1142,7 @@ struct ChatRecentActionsEntry: Comparable, Identifiable { (.banSendStickers, self.presentationData.strings.Channel_AdminLog_BanSendStickersAndGifs), (.banEmbedLinks, self.presentationData.strings.Channel_AdminLog_BanEmbedLinks), (.banSendPolls, self.presentationData.strings.Channel_AdminLog_SendPolls), + (.banSendReactions, self.presentationData.strings.Channel_AdminLog_SendReactions), (.banAddMembers, self.presentationData.strings.Channel_AdminLog_AddMembers), (.banEditRank, self.presentationData.strings.Channel_AdminLog_EditRank), (.banPinMessages, self.presentationData.strings.Channel_AdminLog_PinMessages), From dda6054ef1bd53bcfa2ce5ac5e3796576002a79c Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 23:18:17 +0400 Subject: [PATCH 27/69] Spec: ListView.isStrictlyScrolledToPinToEdgeItem Design doc for filling in the empty stub at ListView.swift:2674. Defines the "strictly scrolled" condition as the equation that the pin-to-edge scroll math at line 3115 lands on: apparentFrame.maxY == (visibleSize.height - insets.bottom) + scrollPositioningInsets.bottom with a 0.5pt tolerance. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...sStrictlyScrolledToPinToEdgeItem-design.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md diff --git a/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md b/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md new file mode 100644 index 0000000000..544020e7b5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md @@ -0,0 +1,68 @@ +# `ListViewImpl.isStrictlyScrolledToPinToEdgeItem` — design + +## Goal + +Implement the public method `isStrictlyScrolledToPinToEdgeItem()` on `ListViewImpl` (in `submodules/Display/Source/ListView.swift`). + +The method returns `true` when the list is currently scrolled to the exact resting position of its pin-to-edge target — i.e. the item that lies on the edge of `insets.bottom` is the current pin-to-edge target (the lowest-index item with `pinToEdgeWithInset == true`). + +A stub already exists at `submodules/Display/Source/ListView.swift:2674` (uncommitted, in the working tree) and is the slot to fill in. + +## Definition of "lies on the edge" + +Aligned with the existing pin-to-edge scroll math (`ListView.swift:3115`): + +```swift +offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom +``` + +When that offset has been applied, the target item satisfies: + +``` +itemNode.apparentFrame.maxY == (visibleSize.height - insets.bottom) + itemNode.scrollPositioningInsets.bottom +``` + +That equation is the "strictly scrolled" condition. + +## Implementation + +```swift +public func isStrictlyScrolledToPinToEdgeItem() -> Bool { + guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { + return false + } + for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } + } + return false +} +``` + +## Behavior table + +| Situation | Return value | +|---|---| +| No item in `self.items` has `pinToEdgeWithInset == true` | `false` | +| Pin-to-edge target exists but its `itemNode` is not currently materialized | `false` | +| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by ≥ 0.5 pt | `false` | +| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by < 0.5 pt | `true` | + +## Design choices + +1. **Algorithm shape: target-first, not edge-first.** "Find target, check whether it's at the edge" rather than the user's literal "find item at the edge, check whether it's the target". The two are equivalent in practice (items don't share `maxY` in a stacked list) and target-first is cheaper / clearer. +2. **`apparentFrame`, not the layout frame.** `apparentFrame` already accounts for in-flight animation offsets and matches the property the scroll-target math at line 3115 uses. The result reflects the true visible state, not a possibly-scheduled layout that hasn't taken effect. +3. **Tolerance: 0.5 pt.** Half a logical point — well under any visible misalignment, generous enough for floating-point and pixel-snap noise on 2x/3x displays. No project-wide convention found; 0.5 pt is the chosen default. +4. **Includes `scrollPositioningInsets.bottom`.** Mirrors line 3115 exactly so that an item with non-zero `scrollPositioningInsets.bottom` is reported as "strictly scrolled" at the same position the scroll-to logic would have left it at. + +## Out of scope + +- No new callers are introduced in this spec. The method is added as public API; consumers will be wired up in subsequent work. +- No changes to `ListViewItem.pinToEdgeWithInset`, `calculatePinToEdgeTopInset`, or any pin-to-edge scroll logic. +- No tests — the codebase has no unit tests and this lives in the rendering layer. + +## Verification + +Build the full app target with the standard `Make.py` invocation in `CLAUDE.md` to confirm the addition compiles. From 1c27b2c426ffc588e180cf06ee123f8148bb699f Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 23:35:19 +0400 Subject: [PATCH 28/69] Animation and cleanup --- ...o-telegramengine-refactor-wave-1-design.md | 170 ----------- .../2026-04-17-listview-pin-to-edge-design.md | 245 ---------------- ...postbox-to-telegramengine-wave-3-design.md | 237 --------------- ...postbox-to-telegramengine-wave-4-design.md | 204 ------------- ...postbox-to-telegramengine-wave-5-design.md | 270 ------------------ ...postbox-to-telegramengine-wave-6-design.md | 134 --------- ...-04-20-decrypt-match-python-port-design.md | 89 ------ ...wifttl-layered-schema-generation-design.md | 195 ------------- ...xtstyleeditscreen-caret-tracking-design.md | 137 --------- ...6-04-22-claude-md-reorganization-design.md | 104 ------- ...ctlistpeer-engine-peer-migration-design.md | 227 --------------- ...-foundpeer-engine-peer-migration-design.md | 193 ------------- ...Controller-engine-peer-migration-design.md | 158 ---------- ...tokentitle-engine-peer-migration-design.md | 71 ----- ...04-24-rcp-peers-engine-migration-design.md | 187 ------------ ...ipant-peer-engine-peer-migration-design.md | 175 ------------ ...sendaspeer-engine-peer-migration-design.md | 141 --------- ...4-25-peerinfo-enclosingpeer-engine-peer.md | 127 -------- ...t-recent-actions-controller-node-design.md | 120 -------- ...count-manager-store-resource-data-drain.md | 149 ---------- ...unt-manager-resource-data-drain-3-sites.md | 148 ---------- ...device-contact-info-subject-engine-peer.md | 183 ------------ ...stbox-wave-106-import-drop-sweep-design.md | 172 ----------- ...sStrictlyScrolledToPinToEdgeItem-design.md | 68 ----- submodules/Display/Source/ListView.swift | 16 ++ .../Chat/ChatControllerLoadDisplayNode.swift | 19 +- 26 files changed, 33 insertions(+), 3906 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md delete mode 100644 docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md delete mode 100644 docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md delete mode 100644 docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md delete mode 100644 docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md delete mode 100644 docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md delete mode 100644 docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md delete mode 100644 docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md delete mode 100644 docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md delete mode 100644 docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md delete mode 100644 docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md delete mode 100644 docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md delete mode 100644 docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md delete mode 100644 docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md diff --git a/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md b/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md deleted file mode 100644 index cfacad1740..0000000000 --- a/docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md +++ /dev/null @@ -1,170 +0,0 @@ -# Postbox → TelegramEngine refactor, wave 1 - -## Goal - -Gradually eliminate direct `import Postbox` from consumer submodules by routing all data access through `TelegramEngine` (`.data.get` / `.data.subscribe` and engine-owned functions). Behavior must be preserved exactly — this is a dependency-shape refactor, not a semantic change. - -This spec covers **wave 1**: the first 10 single-import leaf modules, refactored in bottom-up dependency order. Subsequent waves will be covered by their own specs. - -## Non-goals - -- No refactor inside `TelegramCore` itself — it owns `TelegramEngine` and will keep importing Postbox. -- No refactor inside `Postbox`. -- No behavior or UX changes. No unrelated cleanup. -- No edits to modules outside the 10 chosen by the selection rule. -- Within wave-1 modules, switching Postbox-typed names to their engine typealiases (`PeerId` → `EnginePeer.Id`, etc.) is required and in scope; introducing new engine *wrapper types* that re-encode data is out of scope. -- No generic `engine.transaction { postbox in … }` escape hatch. - -## Guiding rules - -1. Consumers only. `TelegramCore` does **not** `@_exported import Postbox`, so once a module drops its Postbox import every remaining Postbox-type reference must be switched to the engine-typealiased equivalent (`PeerId` → `EnginePeer.Id`, `MessageId` → `EngineMessage.Id`, `MessageIndex` → `EngineMessage.Index`, `MessageTags` → `EngineMessage.Tags`, `MediaId` → `EngineMedia.Id`, etc.). These aliases are identical to their Postbox originals, so the swap is behavior-preserving. -2. **Never typealias the `Postbox` class itself** (or other large umbrella API surfaces such as `Account` or `MediaBox`). Aliasing an umbrella type with something like `EnginePostbox = Postbox` renames without encapsulating — the consumer still has access to the full Postbox API through the alias. It defeats the purpose of the refactor. Narrow utility typealiases (`MemoryBuffer`, `PostboxDecoder`, `PostboxEncoder`, `AdaptedPostboxDecoder`, `MediaResource`, etc.) remain allowed and expected; the ban is specifically on aliasing the large facade types. -3. Prefer existing `Engine*` wrapper types (`EnginePeer`, `EngineMessage`, `EngineMediaResource`) and engine methods; add new engine wrappers only when a call site clearly needs one. -4. Before adding any new engine wrapper, search `submodules/TelegramCore/Sources/TelegramEngine/` for an equivalent by name and shape. Record the search result in the commit that adds the wrapper. -5. Bottom-up dependency order across modules. -6. Full project build after each module, using the command from the global `CLAUDE.md`. -7. A module is done when: no `import Postbox` in its `.swift` files, no `//submodules/Postbox:Postbox` entry in its `BUILD`, full build green, commits landed. - -### Abandonment protocol - -If a module can only be refactored by either (a) typealiasing an umbrella type banned by rule 2, or (b) editing a module outside the wave-1 list, the module is **abandoned for this wave**. Record it in the plan (mark the task Abandoned with the reason) and reduce the wave's done-count accordingly. Do not substitute a new module mid-wave; the wave's scope is fixed at plan time. - -## Wave-1 scope: selecting the 10 modules - -### Candidate pool - -The 30 submodules that currently have Postbox imports in exactly one `.swift` file: - -`ActionSheetPeerItem, ChatInterfaceState, ChatListSearchRecentPeersNode, ChatSendMessageActionUI, ContactListUI, DirectMediaImageCache, DrawingUI, FetchManagerImpl, GalleryData, HorizontalPeerItem, ICloudResources, InAppPurchaseManager, InstantPageCache, InviteLinksUI, ItemListAvatarAndNameInfoItem, ItemListPeerItem, ItemListStickerPackItem, MapResourceToAvatarSizes, PhotoResources, PlatformRestrictionMatching, PresentationDataUtils, PromptUI, SaveToCameraRoll, SelectablePeerNode, ShareItems, SoftwareVideo, StickerPeekUI, StickerResources, TelegramIntents, TelegramNotices` - -### Selection rule - -The implementation plan runs a deterministic selection pass up-front: - -1. Parse each candidate's `BUILD` to compute a reverse-dependency count over the candidate pool (how many other candidates depend on it). Leaves have count 0. -2. Sort by reverse-dep count ascending, then alphabetical. Take the first 10. Write the chosen list into the plan so execution is reproducible. -3. If during execution a chosen module transitively needs a Postbox type not yet exposed via a `TelegramCore` re-export, stop, record the blocker, and skip to the next candidate in the selection-rule ordering — keeping the wave at 10 completed modules. - -### Explicitly deferred (future waves, not this spec) - -- `TelegramUI` (478 files), `SettingsUI` (44), `TelegramCallsUI` (23), `GalleryUI` (16), `PassportUI` (14), `ChatListUI` (13), `AccountContext` (13), and every other module not in the chosen 10. -- `TelegramCore` (non-goal, ever). - -## Per-module playbook - -Each of the 10 modules follows the same deterministic sequence. - -### 1. Inventory - -List every Postbox API referenced in the module. Each reference falls into one of: - -- **Type reference only** — signature or local variable uses a Postbox-defined type (`Peer`, `MessageId`, `Media`, `CachedPeerData`, …). Usually resolvable by `TelegramCore` re-exports or an existing `Engine*` type. -- **`postbox.mediaBox.*`** — media resource access. -- **`account.postbox.transaction { … }`** — read/write transaction. -- **`postbox.combinedView / subscribe(...)` with `PostboxViewKey`** — view subscription. -- **`account.postbox.mediaBox` / `postbox.mediaBox` as a parameter** — plumbing through a public signature. - -### 2. Map each call site to its replacement - -In this priority order: - -1. An existing `TelegramEngine.data.get` / `.subscribe` on a `TelegramEngine.EngineData.Item…`. -2. An existing engine function under `TelegramEngine.{peers, messages, accountData, resources, …}`. -3. An existing re-export from `TelegramCore` (for type-only references). -4. A **new** thin wrapper added to the appropriate `TelegramEngine//` file (see wrapper policy below). Added in `TelegramCore` in a separate preparatory commit before the consumer edit. - -### 3. Edit the consumer - -Replace call sites. Function signatures that took `Postbox` as a parameter become signatures taking `TelegramEngine` (or `AccountContext` where already available in the call site). Public-API changes in these leaf modules are acceptable because no other module currently imports them in a way that depends on Postbox types — verified during step 1. Any break discovered at build time is fixed at the call site in the same commit, or the module is skipped if the fix would require changing a module outside the wave. - -### 4. Drop the dependency - -- Remove `import Postbox` from every file. -- Remove `"//submodules/Postbox:Postbox"` from the module's `BUILD`. - -### 5. Build - -Run the full project build from the global `CLAUDE.md`: - -``` -PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \ - python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development \ - --gitCodesigningUseCurrent \ - --buildNumber 1 \ - --configuration debug_sim_arm64 -``` - -The module is not marked done until the full build is green. Fix failures in place before moving on. - -### 6. Commit - -Commit structure per module, one or two commits: - -1. `TelegramCore: add ` — optional, only if new engine wrappers were needed. -2. `: drop direct Postbox dependency` — consumer edits plus BUILD change. - -## Engine-wrapper policy - -When a call site has no existing engine equivalent, the wrapper is added in `TelegramCore` **before** the consumer edit, in a **separate commit**. - -### Where wrappers go - -- **Data reads and subscriptions** → new `TelegramEngine.EngineData.Item..` struct alongside its peers in `submodules/TelegramCore/Sources/TelegramEngine/Data/Data.swift`. The item's `extract` maps the underlying `PostboxView` to an engine-typed result. -- **Imperative signal-returning calls** → new method on the matching area class (`Peers`, `Messages`, `Resources`, `AccountData`, …) inside `submodules/TelegramCore/Sources/TelegramEngine//`. -- **Media-resource access** → extended on `TelegramEngine.resources` rather than exposing `MediaBox` to consumers. For example: `engine.resources.data(…)`, `engine.resources.fetch(…)`, `engine.resources.status(…)`. Each forwards to `account.postbox.mediaBox.*` internally. -- **Ad-hoc transactions that a consumer was running directly** → a specific purpose-built method on the appropriate area, never a generic transaction escape hatch. If only one call site needs the logic and it's trivial, inline it into a new area method rather than creating a helper. - -### Rules for the wrapper itself - -- Minimal pass-through. No caching, no extra signal plumbing, no bonus features. -- Return type must be nameable from the consumer **without** importing Postbox. That means: an existing `Engine*` wrapper type (`EnginePeer`, `EngineMessage`, `EngineMediaResource`), an existing engine typealias (`EnginePeer.Id`, `EngineMessage.Id`, …), a Swift primitive, or a new `Engine*` typealias added in the same commit. Do **not** return a bare Postbox type. -- Do not introduce new engine wrapper *structs/classes* that re-encode data (those are out of scope for this wave). A new typealias to make an existing Postbox type reachable under an `Engine*` name is allowed and expected. -- Public. -- Consumer must not need anything else from Postbox after the wrapper is in place. -- No deprecation shim. Existing Postbox-using code paths elsewhere in the codebase stay untouched. - -### Discovery step (always runs first) - -Before writing any new wrapper, search `submodules/TelegramCore/Sources/TelegramEngine/` for an existing match by name and shape. Document "searched for X, found/not found" in the commit that adds the wrapper, so future waves don't re-invent it. - -## Verification - -### Per-module static checks (must pass before running the build) - -- `grep -R "^import Postbox" submodules//Sources` returns empty. -- `grep "submodules/Postbox" submodules//BUILD` returns empty. - -### Per-module build check - -- Full project build (the command in §Per-module playbook step 5) is green. -- No warnings-as-errors regressions introduced by the refactor. - -### Wave-completion check - -- All 10 chosen modules satisfy the per-module checks. -- Any new engine wrappers are documented in their respective commits. - -## Risks and mitigations - -- **Public signature changes in a leaf module break an unexpected caller.** Mitigated by the full build per module. Fix at the call site in the same commit, or skip and move on if the fix would pull in scope beyond the wave. -- **A Postbox view has no equivalent engine data item.** Add a new `EngineData.Item` per the wrapper policy. If the mapping is non-trivial (needs its own result type), skip the module and flag it for a future spec. -- **Transitive Postbox usage through a type the module re-exposes publicly.** Caught during Inventory (step 1). If fixing would require editing another module in the wave's dependency graph, skip. -- **A Postbox type has no engine typealias.** Add the typealias in `TelegramCore` (`EngineXxx = Xxx`) in the preparatory commit, then use it in the consumer. Typealias-only additions are explicitly allowed and cheap. -- **Build times.** Full project build per module is slow but accepted — it gives the strongest signal. - -## Follow-ups (not this spec) - -- Successive waves for the remaining ~64 modules in bottom-up order, each its own spec. -- `TelegramUI` (478 files) and `SettingsUI` (44) will likely need a bespoke approach because of scale; they get their own spec when the time comes. -- Whether `AccountContext` itself should eventually stop importing Postbox is deferred. - -## Done definition for this spec - -- Every module in the wave-1 list is either **done** (zero `import Postbox` in its sources, no `//submodules/Postbox:Postbox` in its `BUILD`) or explicitly marked **abandoned** with a recorded reason in the plan. -- Full project build is green at wave end. -- Any new engine wrappers added along the way are documented in their commits. diff --git a/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md b/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md deleted file mode 100644 index 511d52ff9b..0000000000 --- a/docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md +++ /dev/null @@ -1,245 +0,0 @@ -# ListView pin-to-edge first-pinned-item design - -## Goal - -Give `submodules/Display/Source/ListView.swift` the ability to pin a single "first pinned item" to the bottom edge of the scrolling area. The item's `apparentFrame.maxY` should sit at `visibleSize.height - insets.bottom` when the combined height of items with smaller indices ("items above", in list coordinates) is less than the available scrolling-area height. When items above grow past that threshold, the pinning gracefully disengages and the list scrolls normally. - -This produces, in a flipped-chat consumer, the "AI-chat" UX in which a newly-sent outgoing message appears pinned to the visual top of the viewport while later additions fill in toward it. - -## Non-goals - -- Changes to `ListViewItemLayoutParams.availableHeight` or any item-side sizing API. -- A new per-item inset value. The existing `pinToEdgeWithInset: Bool` protocol property (declared on `ListViewItem`, default `false`, currently unread) is repurposed as the trigger; there is no numeric inset argument. -- Coordinating with `stackFromBottom` or `stackFromBottomInsetItemFactor`. Where both mechanisms contribute a top-inset, the existing `max(effectiveInsets.top, …)` chain combines them without additional logic. -- Defining behavior for items with an index greater than the pinned item's. Consumer contract: when a consumer sets `pinToEdgeWithInset = true` on an item, that item must be the highest-index flagged item (in practice, the last item in the data array). Items beyond the pinned item render at their natural frames; the pinning guarantee applies only relative to items with smaller indices. -- Unit tests. The project has no test harness; verification is via full-project build plus manual exercise in a consumer (see "Verification"). - -## Mechanism - -### Trigger rule - -Among materialized item nodes (`self.itemNodes`), find the one with the smallest `index` whose `self.items[index].pinToEdgeWithInset == true`. Call it the pinned node. If there is no such node (no flagged item, or the flagged item is outside the recycling window), pin-to-edge behavior is inert for that frame. - -### Adjustment formula - -``` -visibleArea = visibleSize.height - self.insets.top - self.insets.bottom -totalAboveAndPinned = Σ apparentBounds.height for itemNodes with index ≤ lowestPinnedIndex -pinTopAdjustment = max(0, visibleArea - totalAboveAndPinned) -``` - -**Height source: `apparentBounds.height`, not `frame.size.height`.** `apparentBounds.height` returns `self.apparentHeight`, which `insertNodeAtIndex` (at [ListView.swift:2439](submodules/Display/Source/ListView.swift:2439)) sets to `0.0` for animated insertions and grows via `addApparentHeightAnimation` over the insertion animation's duration. It is *essential* that the helper use this animated value: the ListView's per-tick `vSync` handler (around [ListView.swift:4842](submodules/Display/Source/ListView.swift:4842)) calls `snapToBounds` after each apparentHeight update, so the pin inset is recomputed with the current animated height every frame. With `apparentBounds.height`: - -- **Insertion above pinned** (the critical case): at insertion, new item `X` has `apparentHeight = 0`, so `totalAboveAndPinned` is unchanged, `pinTopAdjustment` is unchanged, `effectiveInsets.top` is unchanged, and the pinned item stays exactly where it was. As `X`'s apparentHeight grows by `dh` per tick, `totalAboveAndPinned` grows by `dh`, `pinTopAdjustment` shrinks by `dh`, and `snapToBounds` shifts items by `-dh` via its `topItemEdge > effectiveInsets.top` clamp; the pinned item's `origin.y` decreases by `dh` while items after `X` (which offsetRanges shifts by `+dh` earlier in the same vSync) are at `pinned.y + dh − dh = pinned.y` — stationary throughout the animation. -- **Initial animated insertion of a pinned item**: `X` starts at `origin.y` = pinned bottom-edge with `apparentHeight = 0` (invisible). As apparentHeight grows by `dh`, `effectiveInsets.top` shrinks by `dh` and `snapToBounds` shifts `origin.y` by `-dh`. `apparentFrame.maxY = origin.y + apparentHeight` stays exactly at the bottom edge for the whole animation; the item appears to grow upward from the bottom edge into its final pinned position. - -Using `frame.size.height` would freeze `totalAboveAndPinned` at its final value on the first tick, so `effectiveInsets.top` would jump to its post-animation value immediately. Insertion above pinned would drag the pinned item up by the new item's full real height on frame 0, then the apparentHeight animation would leave it there — breaking the "pinned stays put" invariant. Initial animated layout would land the item at its final `origin.y` with `apparentHeight = 0`, then grow its content downward into a fixed slot instead of upward from the bottom edge, which is the wrong visual. - -The formula is built from item *heights*, not positions, so it is idempotent: re-running `snapToBounds` or `updateScroller` after a snap offset has already been applied yields the same `pinTopAdjustment`. (A position-based formula would read the post-snap `maxY` and compute 0 on the next pass, undoing the shift.) - -`visibleArea` uses `self.insets`, not `effectiveInsets`, so the threshold at which pinning disengages is purely geometric and is not coupled to other contributors to `effectiveInsets.top` (e.g. `stackFromBottomInsetItemFactor`). Combining with those contributors happens at the application site via `max(…)`. - -### Partial-materialization guard - -Pinning requires the full height of items `[0, lowestPinnedIndex]` to be known. If items[0] is not materialized (not in `self.itemNodes`), some leading items are off-screen above — which can only happen when the scroll area is already full of content above the pinned item — and pinning is then inert for that frame. The helper therefore returns 0 unless an itemNode with `index == 0` is present among the materialized nodes. `ListView`'s recycling window is a contiguous range of indices, so `items[0]` materialized implies `items[0…lowestPinnedIndex]` all materialized. - -### Application - -At each call site that computes `effectiveInsets`, after the existing `stackFromBottomInsetItemFactor` branch: - -```swift -let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() -if pinToEdgeTopInset > 0.0 { - effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset) -} -``` - -This piggybacks on the virtual-top-inset mechanism that `stackFromBottomInsetItemFactor` already uses: raising `effectiveInsets.top` shifts the scroll content downward by that amount, positioning the pinned item's `maxY` at the bottom edge. When `pinTopAdjustment` is 0 (items above have reached the available area, or the guard tripped), no contribution is made and scrolling is ordinary. - -### Inset-transition correction - -There is a third integration point, separate from the two `effectiveInsets` call sites: the inset-transition code in `deleteAndInsertItemsTransaction` around [ListView.swift:3167-3188](submodules/Display/Source/ListView.swift:3167). This block runs whenever `updateSizeAndInsets` is non-nil and shifts every item's frame by an `offsetFix` in order to keep the list visually coherent across the inset/size change. - -In the "top-inset" branch (the `else` at line 3173), the existing formula is: - -```swift -offsetFix = updateSizeAndInsets.insets.top - self.insets.top -``` - -When pinning is engaged, this is wrong: a change in `self.insets.top` is exactly compensated by an opposite change in `pinTopAdjustment` (since `visibleArea = visibleSize.height - insets.top - insets.bottom` moves in lockstep with `insets.top`), so the *effective* top inset (`insets.top + pinTopAdjustment`) doesn't move. But `offsetFix` uses only the raw top delta, so it shifts every item's frame by `top_delta`. The list visibly jumps by that amount until the next `snapToBounds`/`updateScroller` pass corrects it — a keyboard toggle produces a jitter equal to the keyboard's top-inset contribution. - -The correction: capture `self.calculatePinToEdgeTopInset()` before *either* `self.visibleSize` or `self.insets` is reassigned; compute it again after both are updated; and, in the top-inset branch only, add `(updated - previous)` to `offsetFix`. This makes `offsetFix` equal the *effective* top-inset delta rather than the raw one. - -**Ordering matters.** The existing code updates `self.visibleSize` on [ListView.swift:3165](submodules/Display/Source/ListView.swift:3165) (right after entering the inset-transition branch) and `self.insets` on [ListView.swift:3183](submodules/Display/Source/ListView.swift:3183) (later, just before the `offsetFix` shift is applied). The `previousPinToEdgeTopInset` measurement must happen before line 3165 — not between 3165 and 3183 — because `calculatePinToEdgeTopInset` reads `self.visibleSize` to form `visibleArea`. Measuring after 3165 captures a hybrid state (new visibleSize, old insets) that never existed; the resulting delta is wrong whenever `visibleSize` changes in the same transaction. - -Initial layout is the case that surfaces this: the old `self.visibleSize = CGSize.zero`, so `visibleArea ≤ 0` and the helper correctly returns 0 ("no prior pinning") when measured before line 3165. Measured after line 3165, `visibleArea` is the full new screen size and the helper returns a fake "previous" pin inset roughly equal to the real post-transaction pin inset — delta cancels out, and the sign of `offsetFix` flips negative, shifting items in the wrong direction. `snapToBounds` then pulls them back to the right resting position, but the intermediate `offsetFix` value propagates into `sizeAndInsetsOffset` and the `-completeOffset` animation `fromValue`, producing a visible mis-offset on the first frame. - -Rotation (which changes both visibleSize and insets in one transaction) has the same structural issue but is less dramatic because the old visibleSize is non-zero. - -The other two branches don't need the correction: - -- `snapToBottomInsetUntilFirstInteraction` branch (line 3172): its formula `offsetFix = -(new.bottom - old.bottom)` already coincidentally equals `effective_top_delta` when pin is engaged. Working through it: `effective_top_delta = top_delta + pin_delta = top_delta - (top_delta + bottom_delta) = -bottom_delta`. -- `isTracking` branch (line 3170): intentionally sets `offsetFix = 0` and defers repositioning to `snapToBounds`, which already consults pin-aware `effectiveInsets` via the helper. - -### scrollToItem override - -`scrollToItem` at [ListView.swift:3058-3104](submodules/Display/Source/ListView.swift:3058) computes an offset from the target item's `apparentFrame` and the raw `self.insets`, then shifts every item's frame by that offset. When the target is the pin-to-edge target, most `ListViewScrollPosition` variants (`.top`, `.center`, `.visible`, and `.bottom(nonzero)`) compute an offset that drags the pinned item away from its pinned position; the subsequent `snapToBounds` at [ListView.swift:3198](submodules/Display/Source/ListView.swift:3198) / [3360](submodules/Display/Source/ListView.swift:3360) re-imposes pinning on the next pass. The net visible effect is a transient shift in the pre-snap direction, spurious `didScrollWithOffset` callbacks with the wrong value, and (for animated scrolls) a wrong starting frame for the insertion/scroll animation. - -The override: when the target item is the pin-to-edge target (the smallest-index materialized item with `pinToEdgeWithInset == true`, and pinning is actually engaged per `calculatePinToEdgeTopInset() > 0`), bypass the `switch scrollToItem.position` and compute the offset directly as: - -```swift -offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -``` - -This matches the existing `.bottom(0)` case shape. `apparentFrame.maxY` is the right value because of the same per-tick invariant that makes the helper use `apparentBounds.height`: throughout the animation, `apparentFrame.maxY = origin.y + apparentHeight` stays at the pinned bottom edge, so the offset is `0` at every tick — no spurious shift during an in-flight animation. Using `frame.maxY` instead would produce a nonzero offset equal to `apparentHeight − real_height` during the animation, which would fight the `vSync` snap logic and break the animation. - -For non-pinned targets, or for the pinned target when pinning is disengaged (`calculatePinToEdgeTopInset() == 0`), the existing `switch` runs unchanged. - -## Code changes - -### New helper on `ListViewImpl` - -```swift -private func calculatePinToEdgeTopInset() -> CGFloat { - // Pass 1: find the smallest-index flagged item among materialized nodes. - var lowestPinnedIndex: Int = Int.max - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { - lowestPinnedIndex = index - } - } - guard lowestPinnedIndex != Int.max else { return 0.0 } - - // Pass 2: sum heights of items[0 ... lowestPinnedIndex], and require items[0] - // to be materialized (guarantees items[0 ... lowestPinnedIndex] are all present, - // since the recycling window is contiguous in index). - var totalAboveAndPinned: CGFloat = 0.0 - var sawIndexZero = false - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { - sawIndexZero = true - } - if index <= lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } - } - guard sawIndexZero else { return 0.0 } - - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - return max(0.0, visibleArea - totalAboveAndPinned) -} -``` - -### Call-site diffs - -**`snapToBounds(…)` — around [ListView.swift:1181-1185](../../submodules/Display/Source/ListView.swift):** -After the existing `stackFromBottomInsetItemFactor` adjustment of `effectiveInsets.top`, add the `pinToEdgeTopInset` block shown above. - -**`updateScroller(…)` — around [ListView.swift:1612-1616](../../submodules/Display/Source/ListView.swift):** -Same diff. - -**`deleteAndInsertItemsTransaction(…)` inset-transition block — around [ListView.swift:3162-3188](../../submodules/Display/Source/ListView.swift):** -Capture the pin inset before *either* `self.visibleSize` or `self.insets` is reassigned (i.e., before line 3165 in the current code); track whether the top-inset branch was taken; and after `self.insets` and `self.visibleSize` are updated, add the pin-inset delta to `offsetFix` if the top-inset branch was taken: - -```swift -let previousPinToEdgeTopInset = self.calculatePinToEdgeTopInset() -let previousVisibleSize = self.visibleSize -self.visibleSize = updateSizeAndInsets.size - -var offsetFix: CGFloat -var offsetFixUsesEffectiveTopInset = false -let insetDeltaOffsetFix: CGFloat = 0.0 -if (self.isTracking && !self.allowInsetFixWhileTracking) || isExperimentalSnapToScrollToItem { - offsetFix = 0.0 -} else if self.snapToBottomInsetUntilFirstInteraction { - offsetFix = -updateSizeAndInsets.insets.bottom + self.insets.bottom -} else { - offsetFix = updateSizeAndInsets.insets.top - self.insets.top - offsetFixUsesEffectiveTopInset = true -} - -offsetFix += additionalScrollDistance - -self.insets = updateSizeAndInsets.insets -self.headerInsets = updateSizeAndInsets.headerInsets ?? self.insets -self.scrollIndicatorInsets = updateSizeAndInsets.scrollIndicatorInsets ?? self.insets -self.itemOffsetInsets = updateSizeAndInsets.itemOffsetInsets -self.ensureTopInsetForOverlayHighlightedItems = updateSizeAndInsets.ensureTopInsetForOverlayHighlightedItems -self.visibleSize = updateSizeAndInsets.size - -if offsetFixUsesEffectiveTopInset { - let updatedPinToEdgeTopInset = self.calculatePinToEdgeTopInset() - offsetFix += updatedPinToEdgeTopInset - previousPinToEdgeTopInset -} -``` - -**`scrollToItem` handler — around [ListView.swift:3058-3104](../../submodules/Display/Source/ListView.swift):** -Inside the existing `for itemNode in self.itemNodes { if ... index == scrollToItem.index { ... } }` block, right after `let insets = self.insets` and before the `switch scrollToItem.position`: - -```swift -var isPinToEdgeTarget = false -if self.calculatePinToEdgeTopInset() > 0.0, - index >= 0, index < self.items.count, - self.items[index].pinToEdgeWithInset { - isPinToEdgeTarget = true - for otherNode in self.itemNodes { - guard let otherIndex = otherNode.index else { continue } - guard otherIndex >= 0, otherIndex < self.items.count else { continue } - if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset { - isPinToEdgeTarget = false - break - } - } -} - -var offset: CGFloat -if isPinToEdgeTarget { - offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -} else { - switch scrollToItem.position { - // … existing .bottom / .top / .center / .visible cases, unchanged … - } -} -``` - -The `isPinToEdgeTarget` check re-derives "smallest-index materialized flagged item" rather than factoring a shared helper, to keep the pin-to-edge API surface small (the existing `calculatePinToEdgeTopInset()` helper is the only public-within-file surface). The duplication is ~6 lines. - -No other source file is modified. `ListViewItem.pinToEdgeWithInset` stays declared where it already is ([ListViewItem.swift:80](../../submodules/Display/Source/ListViewItem.swift), default `false` via the protocol extension). - -## Behavioral consequences - -- **Pinning engaged** (items above shorter than available area): the pinned item's `maxY` lands on `visibleSize.height - insets.bottom`; virtual empty space sits above items[0] so the combined content fills the visible area. -- **Pinning disengaging** (items above reach the available area): `totalAboveAndPinned` grows with each insertion above until it meets `visibleArea`; at that point `pinTopAdjustment` is exactly 0 and the list scrolls normally. The transition through the threshold is continuous in `totalAboveAndPinned`, so there is no visual jump. -- **Drag / rubber-band / deceleration**: the pinned item behaves like any other item during gesture-driven scroll; on settle, `snapToBounds` returns the content to its resting position, which (while pinning is engaged) places the pinned item at the bottom edge. -- **Insertion at index 0 while pinning is engaged**: the pinned item's index increments by one; `totalAboveAndPinned` grows by the inserted item's height; `pinTopAdjustment` therefore shrinks by the same amount; and the inflated `effectiveInsets.top` shrinks by the same amount in turn. Working through the arithmetic: the pinned item's final `origin.y = effectiveInsets.top + (totalAboveAndPinned - pinned.height)` stays identical before and after, so the pinned item is visually stationary across the insertion. The newly-inserted item appears above it. -- **Flag toggle on an item**: update/layout path triggers `snapToBounds` and `updateScroller`; the helper recomputes. -- **Multiple flagged items**: only the smallest-index materialized flagged node anchors. Others render normally. -- **`stackFromBottom` + `pinToEdgeWithInset` both active**: both mechanisms contribute to `effectiveInsets.top` via `max(…)`; the larger contribution wins. No coordination logic is added. -- **Flagged item outside recycling window**: helper returns 0 at the `lowestPinnedIndex != Int.max` guard; pinning re-engages when the node re-materializes. -- **items[0] outside recycling window**: helper returns 0 at the `sawIndexZero` guard. This state can only arise when content above the pinned item has already exceeded the available area (pushing leading items out of the recycling window), at which point pinning should be inert anyway — the guard makes that explicit and protects against under-counting heights. -- **Empty list**: no `itemNode` has an `index`; both loops complete with `lowestPinnedIndex == Int.max`; returns 0. - -## Verification - -No unit tests exist in the project (per CLAUDE.md). Verification path: - -1. Full-project build: - ``` - source ~/.zshrc 2>/dev/null; \ - PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \ - python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber 1 --configuration debug_sim_arm64 - ``` -2. Manual exercise in a consumer that sets `pinToEdgeWithInset = true` on one item. Confirm: the flagged item sits at the bottom edge; inserting non-flagged items at index 0 keeps the flagged item visually anchored; once items above fill the available area, further scrolling is ordinary. - -Because no existing item overrides `pinToEdgeWithInset` from its default `false`, the existing app surface is unaffected; any regression can only appear in a new consumer. - -## Risk - -The `effectiveInsets.top` contribution has to be computed identically at both call sites. Divergence (for example, `snapToBounds` adding the pin inset but `updateScroller` not) would cause the scroller's `contentSize` / `contentOffset` to disagree with the target scroll position produced by `snapToBounds`, producing scroll jumps. A shared helper — `calculatePinToEdgeTopInset` — and the identical diff applied at both sites is the defense against that. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md deleted file mode 100644 index 2109717f29..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md +++ /dev/null @@ -1,237 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 3: MediaBox fetch/status/data facades + SaveToCameraRoll - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1 and 2 (`docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md`, `docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md`). - -## Goal - -1. Unblock the full-module de-Postboxing of `submodules/SaveToCameraRoll` (abandoned in Wave 2) by adding engine-side facades for the `mediaBox` methods it uses. -2. Migrate `SaveToCameraRoll`'s three public functions to use those facades, drop `import Postbox` from the module, and update all call sites. - -This wave follows the validated Wave-2 shape ("per-API migration, modify in place, update all call sites in one commit"), not the Wave-1 shape ("per-module Postbox drop"). - -## Non-goals - -- Migrating any caller file (`InstantPageUI`, `BrowserUI`, `GalleryUI`, `ShareController`, `TelegramUI`, etc.) to drop its `import Postbox`. Each imports Postbox for many unrelated reasons; this wave only changes how they invoke `SaveToCameraRoll`. -- Adding facades for other `mediaBox` methods beyond the three SaveToCameraRoll needs (`cachedResourceRepresentation`, `completedResourcePath`, `storeResourceData`, etc.). Additive work belongs in future waves when a consumer needs them. -- Wrapping `FetchResourceSourceType` / `FetchResourceError` — these remain Postbox types, exposed by the `fetch` facade as a documented accepted leak. SaveToCameraRoll does not inspect these values. -- Adding `.incremental(waitUntilFetchStatus:)` to the `data` facade, or any of `range` / `statsCategory` / `reportResultStatus` / `preferBackgroundReferenceRevalidation` / `continueInBackground` to the `fetch` facade. - -## Scope and inventory - -### New engine surface - -Three thin forwarding methods added to `TelegramEngine.Resources` in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. No new wrapper structs or classes. - -```swift -public extension TelegramEngine { - final class Resources { - // ...existing methods... - - public func fetch( - reference: MediaResourceReference, - userLocation: MediaResourceUserLocation, - userContentType: MediaResourceUserContentType - ) -> Signal { - return fetchedMediaResource( - mediaBox: self.account.postbox.mediaBox, - userLocation: userLocation, - userContentType: userContentType, - reference: reference - ) - } - - public func status( - resource: EngineMediaResource - ) -> Signal { - return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) - |> map { EngineMediaResource.FetchStatus($0) } - } - - public func data( - resource: EngineMediaResource, - pathExtension: String?, - waitUntilFetchStatus: Bool - ) -> Signal { - return self.account.postbox.mediaBox.resourceData( - resource._asResource(), - pathExtension: pathExtension, - option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) - ) - |> map { EngineMediaResource.ResourceData($0) } - } - } -} -``` - -Design choices: - -- **`data` takes a `waitUntilFetchStatus: Bool`**, not Postbox's `ResourceDataRequestOption` enum. SaveToCameraRoll only ever uses `.complete(waitUntilFetchStatus:)`. If a future consumer needs `.incremental(...)`, extend the facade at that point. -- **`fetch` takes only the 4 parameters SaveToCameraRoll uses.** `range`, `statsCategory`, `reportResultStatus`, `preferBackgroundReferenceRevalidation`, `continueInBackground` can be added additively when a consumer requires them. -- **`reference:` keeps the `MediaResourceReference` Postbox type.** Callers construct it inline via `mediaReference.resourceReference(resource)` and pass it without a local binding; no `import Postbox` is induced at the call site. -- **No wrapping of `FetchResourceSourceType` / `FetchResourceError`.** SaveToCameraRoll calls `.start()` on the `fetch` signal without inspecting the value; it does not import Postbox merely to use these types. Recorded here as an accepted leak. - -### SaveToCameraRoll public API changes - -The enum payload and three public function signatures change. Every caller breaks until updated. - -Before: - -```swift -public enum FetchMediaDataState { - case progress(Float) - case data(MediaResourceData) -} - -public func fetchMediaData( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, forceVideo: Bool = false -) -> Signal<(FetchMediaDataState, Bool), NoError> - -public func saveToCameraRoll( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, video: AnyMediaReference? = nil -) -> Signal - -public func copyToPasteboard( - context: AccountContext, postbox: Postbox, - userLocation: MediaResourceUserLocation, - mediaReference: AnyMediaReference -) -> Signal -``` - -After: - -```swift -public enum FetchMediaDataState { - case progress(Float) - case data(EngineMediaResource.ResourceData) -} - -public func fetchMediaData( - context: AccountContext, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, forceVideo: Bool = false -) -> Signal<(FetchMediaDataState, Bool), NoError> - -public func saveToCameraRoll( - context: AccountContext, - userLocation: MediaResourceUserLocation, - customUserContentType: MediaResourceUserContentType? = nil, - mediaReference: AnyMediaReference, video: AnyMediaReference? = nil -) -> Signal - -public func copyToPasteboard( - context: AccountContext, - userLocation: MediaResourceUserLocation, - mediaReference: AnyMediaReference -) -> Signal -``` - -### SaveToCameraRoll internal changes - -- `var resource: MediaResource?` → `var resource: TelegramMediaResource?` (TelegramCore protocol; matches CLAUDE.md cheat-sheet guidance). `representation.resource` and `file.resource` already return `TelegramMediaResource`, so no wrapping is needed at assignment. -- `fetchedMediaResource(mediaBox: postbox.mediaBox, …)` → `context.engine.resources.fetch(reference: mediaReference.resourceReference(resource), userLocation: userLocation, userContentType: userContentType)`. -- `postbox.mediaBox.resourceStatus(resource)` → `context.engine.resources.status(resource: EngineMediaResource(resource))`. The `switch status { case .Local … }` body is unchanged because `EngineMediaResource.FetchStatus` has the same cases (`.Local`, `.Remote(progress:)`, `.Fetching(isActive:progress:)`, `.Paused(progress:)`). -- `postbox.mediaBox.resourceData(resource, pathExtension: fileExtension, option: .complete(waitUntilFetchStatus: true))` → `context.engine.resources.data(resource: EngineMediaResource(resource), pathExtension: fileExtension, waitUntilFetchStatus: true)`. -- Local `MediaResourceData` bindings (`mainData: MediaResourceData?`, `videoData: MediaResourceData?`) and `case let .data(data):` destructurings → use `EngineMediaResource.ResourceData`. -- Field renames inside SaveToCameraRoll: `data.complete` → `data.isComplete`. `data.path` unchanged. `data.size` is not used internally. -- `import Postbox` removed from the file. - -### Call-site migration (23 sites, 14 files) - -Two mechanical edits per site. - -Edit A — drop the `postbox:` argument: - -```swift -// Before -saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: …, mediaReference: …) -// After -saveToCameraRoll(context: context, userLocation: …, mediaReference: …) -``` - -Edit B — update `FetchMediaDataState.data` field accesses at the ~7 sites that destructure `fetchMediaData` results: - -- `data.complete` → `data.isComplete` -- `data.path` → unchanged -- `data.size` → `data.availableSize` (if used; likely not) - -Inventory (captured 2026-04-18): - -| Module | File | Calls | -|---|---|---| -| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 | -| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 (destructures) | -| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 (destructures) | -| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 | -| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 (one destructures) | -| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 (destructures) | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 (destructures) | -| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 (destructures) | -| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 | -| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 | -| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 | -| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 (destructures) | -| ShareController | `Sources/ShareController.swift` | 3 | - -**Execution-time re-inventory:** before editing any code, the executor must re-grep for `fetchMediaData|saveToCameraRoll|copyToPasteboard` call sites across `submodules/`. If the count or file list drifts meaningfully from this table, abandon editing and revise the plan. - -### Postbox-drop tally update - -- `SaveToCameraRoll` joins the tally of modules free of `import Postbox`. -- No caller file is expected to drop `import Postbox` in this wave. - -## Commit plan - -Two commits, landing in order on `refactor/postbox-to-engine-wave-3`. - -### C1 — `TelegramEngine.Resources: add fetch/status/data facades` - -- Touches only `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. -- Adds the three methods from the "New engine surface" section above. No behavior changes; no consumer changes. -- Buildable in isolation. - -### C2 — `SaveToCameraRoll: drop import Postbox via engine.resources facades` - -Atomic; must land as one commit because signature changes break every unmigrated caller. - -- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`: public signature changes, `FetchMediaDataState.data` payload switch, internal rewrites, `import Postbox` removal. -- All 23 call sites in the inventory table updated in the same commit. -- ~7 destructuring sites also get the `data.complete` → `data.isComplete` rename. - -## Build verification - -Per CLAUDE.md, the only verification available is a full project build. No unit tests exist in the repo. - -- After C1: full build. -- After C2: full build. - -Both builds use the standard command from `CLAUDE.md` (Telegram build recipe with `--configuration debug_sim_arm64`), prefixed with `source ~/.zshrc 2>/dev/null;` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. - -## Risks and mitigations - -- **New call site appears between planning and execution.** Mitigation: re-grep at execution time before editing; abandon & revise if count drifts meaningfully. -- **`FetchResourceSourceType` / `FetchResourceError` are Postbox types.** Mitigation: SaveToCameraRoll never inspects these; future consumers that need to pattern-match will wrap these types in a later wave. -- **A consumer turns out to need a mediaBox facade not in this spec** (e.g., `cachedResourceRepresentation`). Mitigation: out of scope. Abandon that caller's migration; the facade commit still stands on its own. -- **`context.engine` unavailable at some call site.** Risk minimal: `AccountContext.engine` is a protocol requirement in `submodules/AccountContext/Sources/AccountContext.swift`, so it is universally available at any site that already has `context: AccountContext`. All 23 sites match. -- **ShareController:2406 uses a non-`context.account.postbox` Postbox.** At `submodules/ShareController/Sources/ShareController.swift:2406`, the call reads `let postbox = self.currentContext.stateManager.postbox` and passes that as `postbox:`. After migration, SaveToCameraRoll internally uses `context.account.postbox.mediaBox` via the engine. In the gated `ShareControllerAppAccountContext` path, `accountContext.context.account.stateManager` should match `self.currentContext.stateManager`, so the two postboxes are equivalent; verify this at execution time before editing. If they can diverge (e.g., during share-extension account switching), this specific call site must be abandoned with a recorded reason — the rest of the wave is unaffected. -- **Umbrella-type rule-2 compliance.** No `Postbox` / `Account` / `MediaBox` typealias is added. No new wrapper struct is introduced. ✅ - -## Abandonment criteria - -If any call site cannot be migrated mechanically — for example, it passes a non-`context.account.postbox` custom `Postbox`, or constructs a `MediaResourceReference` in a way that forces a retained `import Postbox` in a file the wave intends to de-Postbox — abandon that specific call site with a recorded reason in the plan. The facade commit (C1) still stands on its own; SaveToCameraRoll's internal migration still lands if at least the other callers migrate. If too many call sites abandon, abandon the whole wave and record lessons. - -## Expected outcome - -- `TelegramEngine.Resources` has three new thin forwarders. -- `SaveToCameraRoll` no longer imports Postbox. -- Running tally of Postbox-free consumer modules: Wave 1 cohort + `StickerPeekUI`, `PromptUI`, `PresentationDataUtils` (standalone) + `MapResourceToAvatarSizes` (Wave 2) + **`SaveToCameraRoll` (Wave 3)**. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md deleted file mode 100644 index a57c892b02..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 4: `TelegramEngine.Stickers.uploadSticker` facade migration - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–3. -- `docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md` -- `docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md` -- `docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md` - -## Goal - -Migrate the public facade `TelegramEngine.Stickers.uploadSticker` so its signature and its return-enum payload no longer leak Postbox-domain types: - -- `peer: Peer → EnginePeer` -- `resource: MediaResource → EngineMediaResource` -- `thumbnail: MediaResource? → EngineMediaResource?` -- `UploadStickerStatus.complete(CloudDocumentMediaResource, String) → .complete(EngineMediaResource, String)` - -Follows the validated Wave-2 shape ("per-facade-API migration, modify in place, update call sites in the same commit"). - -## Non-goals - -- Migrating the caller files (`ImportStickerPackUI/Sources/ImportStickerPackController.swift`, `TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift`) to drop `import Postbox`. Each imports Postbox for unrelated reasons; this wave only changes how they invoke `uploadSticker`. -- Migrating other `TelegramEngine.Stickers` facades (e.g. `createStickerSet`, `addStickerToStickerSet`) that have similar Peer/MediaResource leaks. Future-wave work. -- Wrapping or renaming `CloudDocumentMediaResource` itself (it's a TelegramCore-defined class conforming to the `TelegramMediaResource` protocol). It stays usable internally; this wave just stops exposing it in the public enum payload. -- Changes to `_internal_uploadSticker`'s signature. It continues to take raw `Peer` and `MediaResource`, consistent with CLAUDE.md's "internal Postbox-facing layer stays raw" rule — with one intentional one-line exception documented below. - -## Scope - -### Core change: `UploadStickerStatus` enum - -In `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift`: - -```swift -// Before (line 7-10) -public enum UploadStickerStatus { - case progress(Float) - case complete(CloudDocumentMediaResource, String) -} - -// After -public enum UploadStickerStatus { - case progress(Float) - case complete(EngineMediaResource, String) -} -``` - -`UploadStickerStatus` is both the public return type of the facade and the return type of `_internal_uploadSticker`. Rather than split it into two enums (one raw for the internal layer, one engine-wrapped for the public facade), this wave keeps one enum and wraps at the single `.complete(...)` construction site inside `_internal_uploadSticker` (line ~97 of the same file): - -```swift -// Before -return .single(.complete(uploadedResource, file.mimeType)) - -// After -return .single(.complete(EngineMediaResource(uploadedResource), file.mimeType)) -``` - -**This one-line construction of `EngineMediaResource` inside `_internal_uploadSticker` is a narrow, spec-allowed exception** to CLAUDE.md's "internal Postbox-facing stays raw" guideline. The alternative (splitting the enum) fragments a simple public surface and duplicates bookkeeping. `EngineMediaResource` is defined in `TelegramCore` and already accessible without additional imports. - -### Facade signature migration - -In `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift`: - -```swift -// Before (line 85-87) -public func uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer, resource: resource, thumbnail: thumbnail, alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) -} - -// After -public func uploadSticker(peer: EnginePeer, resource: EngineMediaResource, thumbnail: EngineMediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer._asPeer(), resource: resource._asResource(), thumbnail: thumbnail?._asResource(), alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) -} -``` - -The facade bridges all three type swaps (`peer._asPeer()`, `resource._asResource()`, `thumbnail?._asResource()`). `_internal_uploadSticker`'s own signature does not change. - -### Call-site migration (2 sites, 2 files) - -**1. `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91`** — argument simplification and destructure simplification: - -```swift -// Before -signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource._asResource(), thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) - |> map { result -> (UUID, StickerVerificationStatus, EngineMediaResource?) in - switch result { - case .progress: - return (sticker.uuid, .loading, nil) - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, EngineMediaResource(resource)) - } else { - return (sticker.uuid, .declined, nil) - } - } - } -``` - -becomes: - -```swift -signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource, thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) - |> map { result -> (UUID, StickerVerificationStatus, EngineMediaResource?) in - switch result { - case .progress: - return (sticker.uuid, .loading, nil) - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, resource) - } else { - return (sticker.uuid, .declined, nil) - } - } - } -``` - -Three changes: -- `peer` in this enclosing closure is a raw `Peer` (from `postbox.loadedPeerWithId(...)`, whose signature is `Signal`). Currently the facade takes `Peer` so the identifier is passed as-is. After the facade moves to `EnginePeer`, wrap at the call: `peer: EnginePeer(peer)`. -- `resource._asResource()` → `resource` (the local `resource` is already `EngineMediaResource`). -- `EngineMediaResource(resource)` → `resource` in the destructure (the destructured `resource` is now `EngineMediaResource` directly). - -**2. `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099`** — unwrap removed, wraps added: - -```swift -// Before -return context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) - -// After -return context.engine.stickers.uploadSticker(peer: peer, resource: EngineMediaResource(resource), thumbnail: file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) -``` - -The enclosing block is a nested `mapToSignal` chain starting at line ~8084. The `UploadStickerStatus` payload migration cascades through several lines in this block: - -- **Line 8097** — `.complete(resource, mimeType)` where `resource` was narrowed via `if let resource = resource as? CloudDocumentMediaResource`. After the payload migration this `.complete(...)` constructor takes `EngineMediaResource`, so wrap: `.complete(EngineMediaResource(resource), mimeType)`. -- **Line 8099** — the main facade call. `peer._asPeer()` → `peer` (the local `peer` is an `EnginePeer`, confirmed by the current `_asPeer()`); `resource` → `EngineMediaResource(resource)` (the local `resource` here is a raw `MediaResource` from the outer enum's `.complete(resource)` case); `file.previewRepresentations.first?.resource` → `file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }`. -- **Line 8105** — `case let .complete(resource, _):` destructures the inner `UploadStickerStatus`. After migration, `resource` has type `EngineMediaResource`. -- **Line 8106** — `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, …)` — `stickerFile` is declared with `resource: TelegramMediaResource, thumbnailResource: TelegramMediaResource?, …`, so unwrap: `stickerFile(resource: resource._asResource(), thumbnailResource: file.previewRepresentations.first?.resource, …)`. The `thumbnailResource` argument is already a `TelegramMediaResource?` and needs no change. -- **Line 8119** — `ImportSticker(resource: .standalone(resource: resource), …)`. `MediaResourceReference.standalone(resource:)` takes `MediaResource`, so unwrap: `.standalone(resource: resource._asResource())`. -- **Line 8138** — same as 8119 inside the `.addToStickerPack` case. -- **Line 8178** — outer-handler destructure `case let .complete(resource, _):`. After migration, `resource` is `EngineMediaResource`. -- **Line 8180** — `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, …)`. Two unwrap sites here: `resource: resource._asResource()` for the first argument, and `size: resource._asResource().size ?? 0` for the size read (`EngineMediaResource` does not expose `.size`; only `MediaResource` does). Introduce a local `let rawResource = resource._asResource()` at the top of the `case` to avoid calling `_asResource()` twice. - -**Execution-time check:** before editing MediaEditorScreen, re-read the full block (roughly lines 8080–8190) and the `stickerFile` function signature (line 9196) to confirm these assumptions. If any additional downstream use of the destructured `resource` appears that wasn't caught above, decide inline whether it needs `._asResource()` or can take `EngineMediaResource` directly. - -## Execution-time re-inventory - -Before editing, re-grep to catch any new call sites: - -```bash -grep -rnE "\.uploadSticker\(" submodules --include="*.swift" | grep -v "/TelegramEngine/Stickers/" -``` - -Expected output lines that pattern-match the facade (not the `MediaEditorScreen`'s private `self.uploadSticker(file, action:)` helper): - -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91` -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099` - -Other `self.uploadSticker(...)` lines in `MediaEditorScreen.swift` (7771, 7808, 7852, 7896, 7913, 7931, 8019) are calls to a private helper method, not the engine facade — leave those untouched. - -If the facade call-site count drifts beyond these two, stop and revise the plan. - -## Commit plan - -One atomic commit covering all four files: - -**C1 — `TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource`** - -- `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift` — enum payload + one `.complete(...)` construction site. -- `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift` — facade signature. -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift` — 1 call site + destructure. -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` — 1 call site. - -Atomicity is required because the enum payload change, facade signature change, and call-site changes are all mutually breaking. - -**C2 — `CLAUDE.md: record wave-4 outcome`** - -- Add a "Wave 4 outcome (2026-04-18)" subsection documenting the facade migrated. -- Remove the `uploadSticker` bullet from "Known future-wave candidates". -- No change to the "Modules currently free of `import Postbox`" running tally (no module is de-Postboxed in this wave). - -## Build verification - -One full project build after the edits, before committing C1. The bazel command from CLAUDE.md, prefixed with `source ~/.zshrc 2>/dev/null;` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. - -## Risks and mitigations - -- **Risk:** a new call site of `engine.stickers.uploadSticker` appears between planning and execution. **Mitigation:** the re-grep above catches this; abandon or extend the plan if so. -- **Risk:** `UploadStickerStatus.complete` is destructured somewhere that accesses `CloudDocumentMediaResource`-specific members. **Mitigation:** grep confirms both known sites use the value generically (wrap or assign directly); no `.stringRepresentation`-style `CloudDocumentMediaResource`-specific access is expected. If found, abandon the wave. -- **Risk:** `MediaEditorScreen:8099`'s `resource` local is already an `EngineMediaResource`. **Mitigation:** inspect the enclosing function at execution time and adjust the wrap accordingly. -- **Risk:** the one-line `EngineMediaResource(uploadedResource)` wrap inside `_internal_uploadSticker` is a narrow deviation from "Postbox-facing layer stays raw". **Mitigation:** spec explicitly calls this out. The alternative (splitting the enum) is worse for a single-line gain; documented and accepted. -- **Rule-2 compliance:** no `Postbox`/`Account`/`MediaBox` typealias introduced; no new wrapper struct. ✅ - -## Abandonment criteria - -If any call-site edit turns out to require a cascading type change elsewhere (e.g. a struct field or signature typed as `CloudDocumentMediaResource`), abandon the wave and record the reason. The single-commit shape means either the whole thing lands or none of it does. - -## Expected outcome - -- `TelegramEngine.Stickers.uploadSticker`'s public surface no longer references `Peer`, `MediaResource`, or `CloudDocumentMediaResource`. -- `UploadStickerStatus.complete`'s payload becomes `(EngineMediaResource, String)`. -- `_internal_uploadSticker`'s signature stays as-is (raw `Peer`/`MediaResource`), with one inline `EngineMediaResource(uploadedResource)` wrap at the result-construction site. -- Two call sites updated; no caller module becomes Postbox-free. -- CLAUDE.md records the outcome and removes the `uploadSticker` entry from "Known future-wave candidates". -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md deleted file mode 100644 index 3442a2ba95..0000000000 --- a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md +++ /dev/null @@ -1,270 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 5: `uploadSecureIdFile` facade + SecureId context migration - -**Date:** 2026-04-18 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–4. - -## Goal - -Complete the last explicitly-named future-wave candidate from CLAUDE.md: migrate `uploadSecureIdFile`'s public surface to stop leaking the `Postbox`/`Network`/`MediaResource` Postbox-domain types, and refactor its caller `SecureIdVerificationDocumentsContext` so the caller stops importing Postbox. - -- `uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` → `(context:, engine: TelegramEngine, resource: EngineMediaResource)`. -- `SecureIdVerificationDocumentsContext` drops its `postbox: Postbox` + `network: Network` stored properties, takes `engine: TelegramEngine` instead, and drops `import Postbox` from the file. -- The one instantiation site updates to pass `engine: self.context.engine`. - -## Non-goals - -- Migrating `SecureIdAccessContext`, `SecureIdVerificationDocument`, `SecureIdVerificationLocalDocument`, or other SecureId-domain types. They live in TelegramCore (not Postbox) already and do not leak. -- Migrating other SecureId-family functions in TelegramCore (e.g., `_internal_requestSecureIdVerification`, etc.). Future-wave work. -- Dropping `import Postbox` from `SecureIdDocumentFormControllerNode.swift`. That file imports Postbox for unrelated reasons. - -## Scope and inventory - -### Files touched (3 in the code commit) - -1. `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift` — facade signature change, 3-line body bridge. -2. `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift` — stored props, constructor, internal call, drop `import Postbox`. -3. `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift` — one-line instantiation call. - -### Facade signature migration (`UploadSecureIdFile.swift`) - -Before: - -```swift -public func uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource) -> Signal { - return postbox.mediaBox.resourceData(resource) - |> mapError { _ -> UploadSecureIdFileError in - } - |> mapToSignal { next -> Signal in - if !next.complete { - return .complete() - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else { - return .fail(.generic) - } - - guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else { - return .fail(.generic) - } - - return multipartUpload(network: network, postbox: postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) - |> mapError { _ -> UploadSecureIdFileError in - return .generic - } - |> mapToSignal { result -> Signal in - switch result { - case let .progress(value): - return .single(.progress(value)) - case let .inputFile(.inputFile(fileData)): - return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data)) - default: - return .fail(.generic) - } - } - } -} -``` - -After: - -```swift -public func uploadSecureIdFile(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource) -> Signal { - return engine.account.postbox.mediaBox.resourceData(resource._asResource()) - |> mapError { _ -> UploadSecureIdFileError in - } - |> mapToSignal { next -> Signal in - if !next.complete { - return .complete() - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else { - return .fail(.generic) - } - - guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else { - return .fail(.generic) - } - - return multipartUpload(network: engine.account.network, postbox: engine.account.postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) - |> mapError { _ -> UploadSecureIdFileError in - return .generic - } - |> mapToSignal { result -> Signal in - switch result { - case let .progress(value): - return .single(.progress(value)) - case let .inputFile(.inputFile(fileData)): - return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data)) - default: - return .fail(.generic) - } - } - } -} -``` - -Three substantive body changes, all in line with the CLAUDE.md rule that "internal Postbox-facing stays raw" — the body is inside TelegramCore itself so it accesses raw Postbox types through `engine.account.postbox` without going through the wave-3 facades: - -- `postbox.mediaBox.resourceData(resource)` → `engine.account.postbox.mediaBox.resourceData(resource._asResource())` (unwrap the engine resource before handing to raw MediaBox). -- `network: network` → `network: engine.account.network`. -- `postbox: postbox` → `postbox: engine.account.postbox`. - -The `_internal_*` convention does not apply here because `uploadSecureIdFile` is itself the facade — there is no separate raw-typed `_internal_uploadSecureIdFile` helper, and this wave does not introduce one. The function continues to have a single definition serving both internal TelegramCore wiring and consumer use. - -### Caller-class migration (`SecureIdVerificationDocumentsContext.swift`) - -Before: - -```swift -import Foundation -import Postbox -import TelegramCore -import SwiftSignalKit - -private final class DocumentContext { - private let disposable: Disposable - - init(disposable: Disposable) { - self.disposable = disposable - } - - deinit { - self.disposable.dispose() - } -} - -final class SecureIdVerificationDocumentsContext { - private let context: SecureIdAccessContext - private let postbox: Postbox - private let network: Network - private let update: (Int64, SecureIdVerificationLocalDocumentState) -> Void - private var contexts: [Int64: DocumentContext] = [:] - private(set) var uploadedFiles: [Data: Data] = [:] - - init(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.postbox = postbox - self.network = network - self.context = context - self.update = update - } - - func stateUpdated(_ documents: [SecureIdVerificationDocument]) { - // ... - disposable.set((uploadSecureIdFile(context: self.context, postbox: self.postbox, network: self.network, resource: info.resource) - // ... - } -} -``` - -After: - -```swift -import Foundation -import TelegramCore -import SwiftSignalKit - -private final class DocumentContext { - private let disposable: Disposable - - init(disposable: Disposable) { - self.disposable = disposable - } - - deinit { - self.disposable.dispose() - } -} - -final class SecureIdVerificationDocumentsContext { - private let context: SecureIdAccessContext - private let engine: TelegramEngine - private let update: (Int64, SecureIdVerificationLocalDocumentState) -> Void - private var contexts: [Int64: DocumentContext] = [:] - private(set) var uploadedFiles: [Data: Data] = [:] - - init(engine: TelegramEngine, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.engine = engine - self.context = context - self.update = update - } - - func stateUpdated(_ documents: [SecureIdVerificationDocument]) { - // ... - disposable.set((uploadSecureIdFile(context: self.context, engine: self.engine, resource: EngineMediaResource(info.resource)) - // ... - } -} -``` - -Changes: - -1. Drop `import Postbox` (line 2). -2. Replace `private let postbox: Postbox` and `private let network: Network` with `private let engine: TelegramEngine`. -3. Constructor: `postbox:, network:, context:, update:` → `engine:, context:, update:`. -4. Constructor body: `self.postbox = postbox; self.network = network` → `self.engine = engine`. -5. Inside `stateUpdated`: `postbox: self.postbox, network: self.network` → `engine: self.engine`; `resource: info.resource` → `resource: EngineMediaResource(info.resource)` (wrap; `info.resource` is `TelegramMediaResource` per `SecureIdVerificationLocalDocument` definition). - -`DocumentContext` inner class is untouched. Other methods in the file are untouched. - -### Instantiation-site edit (`SecureIdDocumentFormControllerNode.swift`) - -Single line, [line 2172](submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift#L2172): - -```swift -// Before -self.uploadContext = SecureIdVerificationDocumentsContext(postbox: self.context.account.postbox, network: self.context.account.network, context: self.secureIdContext, update: { id, state in - -// After -self.uploadContext = SecureIdVerificationDocumentsContext(engine: self.context.engine, context: self.secureIdContext, update: { id, state in -``` - -`self.context` is the outer `AccountContext`. `self.context.engine` is the `TelegramEngine` (universally available via the `AccountContext` protocol). `self.secureIdContext` is the unrelated inner `SecureIdAccessContext` — kept as the `context:` argument. - -## Execution-time re-inventory - -Before editing, re-grep to catch any new call sites: - -```bash -grep -rnE "uploadSecureIdFile\(" submodules --include="*.swift" | grep -v "/SecureId/" -grep -rnE "SecureIdVerificationDocumentsContext\(" submodules --include="*.swift" | grep -v "final class SecureIdVerificationDocumentsContext" -``` - -Expected: exactly 1 match for each — `SecureIdVerificationDocumentsContext.swift:43` and `SecureIdDocumentFormControllerNode.swift:2172` respectively. If either count has drifted, stop and revise the plan. - -## Commit plan - -**C1 — `SecureId: migrate uploadSecureIdFile + context to TelegramEngine`** (atomic) - -- All three files listed above, landing together. - -**C2 — `CLAUDE.md: record wave-5 outcome`** - -- Add `SecureIdVerificationDocumentsContext` to the "Modules currently free of `import Postbox`" running tally. -- Add a "Wave 5 outcome (2026-04-18)" subsection describing the migration. -- Remove the `uploadSecureIdFile` bullet from "Known future-wave candidates". After this, only the 4 permanently-blocked classes remain. - -## Build verification - -One full project build after C1's edits, before committing. Bazel command from CLAUDE.md with `source ~/.zshrc 2>/dev/null;` prefix. - -## Risks and mitigations - -- **Risk:** an additional call site of `uploadSecureIdFile` appears between planning and execution. **Mitigation:** the execution-time re-grep catches this. Expected 1 match. -- **Risk:** `SecureIdDocumentFormControllerNode.swift`'s `self.context` isn't an `AccountContext` at the instantiation site. **Mitigation:** confirm at execution time. The `AccountContext` protocol mandates `var engine: TelegramEngine { get }`, so any concrete `AccountContext` has it. -- **Risk:** behavior regression from `multipartUpload(network: engine.account.network, postbox: engine.account.postbox, …)`. **Mitigation:** these are the same underlying instances as the pre-migration `self.network` / `self.postbox` values (both originate from `self.context.account.network` / `.postbox`). Zero behavior change. -- **Risk:** after `import Postbox` is dropped from `SecureIdVerificationDocumentsContext.swift`, an implicit `Network` type (used elsewhere in the file?) fails to resolve. **Mitigation:** the file's only `Network` usage is in the stored `private let network` and the constructor parameter — both removed. No other `Network` reference survives. -- **Rule-2 compliance:** no `Postbox`/`Account`/`MediaBox` typealias introduced. No new wrapper struct. The facade body's `engine.account.postbox.mediaBox` and `engine.account.network` are internal expressions inside TelegramCore (not public surface). ✅ - -## Abandonment criteria - -If any of the 3 files cannot be migrated mechanically (e.g. `SecureIdDocumentFormControllerNode.swift`'s enclosing class doesn't have an `AccountContext`), abandon the wave and record the reason. The one-commit atomic shape means either the whole thing lands or none of it does. - -## Expected outcome - -- `uploadSecureIdFile`'s public signature references neither `Postbox` nor `Network` nor `MediaResource`. -- `SecureIdVerificationDocumentsContext` no longer imports Postbox and joins the Postbox-free running tally. -- `SecureIdDocumentFormControllerNode.swift` continues to import Postbox for unrelated reasons (no tally impact). -- `uploadSecureIdFile` bullet is removed from CLAUDE.md's "Known future-wave candidates"; after this wave, only the 4 permanently-blocked `TelegramMediaResource`-conforming classes remain in the candidate list. -- Full build succeeds in `debug_sim_arm64`. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md b/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md deleted file mode 100644 index 9de9c52aad..0000000000 --- a/docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Postbox → TelegramEngine refactor, Wave 6: unused `import Postbox` batch sweep - -**Date:** 2026-04-19 -**Status:** Design approved; awaiting implementation plan. -**Predecessors:** Waves 1–5. - -## Goal - -Clear `import Postbox` lines that have become unused across consumer submodules. Previous waves migrated facades and caller-side usages; in many files the last semantic use of a Postbox type was removed but the `import Postbox` line remained. This wave identifies and removes all such dangling imports in a single build-verified commit. - -Unlike waves 1–5, this is not a per-facade or per-module migration. It's a large-blast-radius, zero-semantic-change sweep where the project build is the safety net: anything that compiles is definitionally safe to drop. - -## Non-goals - -- Migrating any facade API or adding typealiases, wrappers, or `engine` plumbing. Pure deletion. -- Touching files inside `submodules/TelegramCore/`, `submodules/Postbox/`, or `submodules/TelegramApi/`. Their `import Postbox` lines are structural, not cleanup-eligible. -- Dropping `import Postbox` from files whose Postbox uses are indirect but real (`Media`, `ItemCollectionId`, `Peer` as a protocol not yet typealiased, etc.). The build tells us which these are. -- Dropping any other unused imports (Foundation, UIKit, etc.). Postbox only. -- Resolving or editing `@_exported import Postbox` lines. Only the plain `import Postbox` on its own line is in scope. - -## Scope - -### Candidate discovery - -Candidate list is generated by: - -```bash -grep -rl "^import Postbox$" submodules --include="*.swift" \ - | grep -vE "/(TelegramCore|Postbox|TelegramApi)/" -``` - -Expected candidate count: on the order of 200–300 files. Only a fraction are actually unused imports; the rest need their `import Postbox` restored after speculative drop. - -### Methodology: speculative-drop + build-verify - -1. **Snapshot** every candidate file's current content. Any of the following is acceptable: - - Copy files to a temp directory keyed by relative path. - - Record the candidate file list plus a single `git stash --keep-index` that captures the pre-edit state. - - Simply rely on `git checkout -- ` to restore, since every snapshot is the committed state at branch HEAD before the wave. - The last option is simplest and chosen for this wave. -2. **Speculative drop:** remove the `import Postbox` line from every candidate. Exact edit: delete the full line matching `^import Postbox$` (not `@_exported import Postbox`, not `import Postbox // comment` — the plain form only). -3. **Full build.** Expect some compile errors — these identify files that actually need the import. -4. **Parse errors.** Each Swift compile error begins with `::: error:`. Collect the set of unique files that have any error. Each of these gets `import Postbox` restored via `git checkout -- `. -5. **Rebuild.** Goal: clean success. -6. **Iterate.** If new errors surface (rare — should only happen when a symbol was exported transitively), restore those files too. Hard cap: 3 iterations. If iteration 3 still fails, abandon and revert the whole wave. -7. **Stage and commit** all surviving per-file diffs (each will be a single-line deletion) as one atomic C1 commit. - -### Error-parsing discipline - -Only restore a file for one of these error categories: - -- `cannot find type 'X' in scope` where `X` is a Postbox-exported symbol. -- `use of unresolved identifier 'X'` where `X` is a Postbox-exported symbol. -- `cannot find 'X' in scope` for Postbox symbols. -- `no such module 'Postbox'` — shouldn't occur unless Bazel deps are broken; if it does, halt and investigate. - -Do NOT restore imports for: - -- Codesign / dependency-graph failures unrelated to Swift compilation. -- Errors in files that weren't among the candidate set (those indicate cascading breakage — halt and investigate). -- Warnings about unused imports (those are the OPPOSITE signal — keep the drop). - -### Automation - -Because the candidate set is large (~200+ files), manual editing is impractical. Use a short helper script (plain bash + `sed`) to: - -1. Write the candidate list to `/tmp/wave-6-candidates.txt`. -2. Run `sed -i '' '/^import Postbox$/d' ` for each candidate. -3. Run the full build, capturing stderr. -4. Extract file paths from error lines. -5. `git checkout --` those files. -6. Rebuild, extract more error paths, repeat. - -Script-wise it's 20–30 lines of bash. The plan will include the exact commands. - -### Out-of-scope files - -- `submodules/TelegramCore/` — never touched. -- `submodules/Postbox/` — never touched. -- `submodules/TelegramApi/` — never touched. -- Files with `@_exported import Postbox` — never touched (but the regex `^import Postbox$` would not match them anyway). - -## Commit plan - -**C1 — `Drop unused import Postbox from N consumer files`** (atomic, one commit, build-verified) - -- All surviving per-file deletions. Each file's only change is a single-line deletion. -- Commit message notes the count (N) and confirms the build-verified methodology. - -**C2 — `CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology`** - -- New "Wave 6 outcome (2026-04-19)" subsection. -- **New permanent guidance subsection** added under "Wave-selection guidance" (not tied to wave 6's specific results), capturing the methodology for future re-runs. Text along the lines of: - - > **Unused-import sweeps are a valid wave shape.** After a round of facade migrations, consumer files accumulate `import Postbox` lines whose last semantic use was removed. Periodically sweep these: - > - > 1. `grep -rl "^import Postbox$" submodules --include="*.swift" | grep -vE "/(TelegramCore|Postbox|TelegramApi)/"` to generate candidates. - > 2. `sed -i '' '/^import Postbox$/d' ` to speculatively drop the import from all candidates. - > 3. Run a full project build. Parse `::: error:` lines to identify files that need the import restored. Restore via `git checkout -- `. - > 4. Rebuild. Iterate up to 3 times. - > 5. Commit surviving drops as one atomic commit. - > - > Re-run after every 2–3 facade-migration waves to convert accumulated cleanup into tally wins. - -- **Running tally update:** add to the "Modules currently free of `import Postbox`" list any module where, after the sweep, no file contains `import Postbox`. Module-level inclusion is stricter than per-file cleanup — only counts when ALL Swift files in a module are clean. - -## Build verification - -- Iteration 1: full build with all candidates dropped. Expect errors. -- Iteration 2 (and optionally 3): full build with failed files restored. -- Final iteration: clean build, required before commit. - -Use the standard CLAUDE.md build command, prefixed with `source ~/.zshrc 2>/dev/null;`. - -## Risks and mitigations - -- **Very long candidate list causes a very long first build iteration.** Bazel will recompile any module whose sources changed, plus downstream dependents. Nearly every consumer module is dropping at least one file's import, so the first build touches most modules. Mitigation: accept the cost; subsequent iterations only touch files where errors surfaced — far fewer recompilations. Total: expect 2 full project rebuilds. -- **Error cascading: a single missing import in an upstream module breaks many downstream files.** Restoring the upstream file's import may silence a large batch of errors at once. Mitigation: restore one pass at a time, rebuild, reassess. -- **Parser brittleness: build output format shifts.** Swift/Bazel output is stable, but diagnostic-rendering flags could differ. Mitigation: after iteration 1, visually inspect a handful of error lines to confirm the `::: error:` pattern holds before automating iteration 2. -- **Stashing/snapshot failure** leaves the working tree in a half-dropped state. Mitigation: since every snapshot is branch HEAD, `git checkout -- ` always restores correctly. If the working tree is hopelessly messed up, `git checkout -- .` restores everything from HEAD — the whole wave can be safely restarted from scratch with zero loss. -- **Hidden `@_exported import Postbox` would bypass the sweep without being touched.** Intentional: those re-export Postbox and must stay. The `^import Postbox$` regex matches only plain imports. -- **Rule-2 compliance:** no new typealiases, no wrapper structs, no public API changes. ✅ - -## Abandonment criteria - -- After 3 iterations, if new errors keep surfacing, the sweep's underlying assumption (per-file isolation) is broken for some module. Abandon: `git checkout -- .`, and record the blocker in CLAUDE.md's wave-6 outcome (not as a success). Consider manual per-file exploration in a future wave. -- If any iteration produces an error that isn't "cannot find type" / "use of unresolved identifier" / similar — halt, investigate, do not blindly restore. - -## Expected outcome - -- Dozens of `import Postbox` lines removed, all build-verified. -- Some consumer modules join the "Postbox-free" running tally when their last Postbox-importing file is swept. -- CLAUDE.md records the outcome and, for future waves, captures the methodology as permanent guidance so subsequent unused-import sweeps can be triggered any time imports accumulate. -- Zero behavior change. diff --git a/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md b/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md deleted file mode 100644 index e8a654b4e5..0000000000 --- a/docs/superpowers/specs/2026-04-20-decrypt-match-python-port-design.md +++ /dev/null @@ -1,89 +0,0 @@ -# Pure-Python port of `decrypt.rb` for fastlane match - -## Goal - -Drop the Ruby toolchain dependency from the iOS build. Replace the `ruby build-system/decrypt.rb` call in `BuildConfiguration.py:110` with a self-contained Python 3 implementation. No new third-party dependencies (no `cryptography` package, no Ruby). - -## Current state - -- `build-system/decrypt.rb` (115 lines) implements fastlane match's V1 (AES-256-CBC via `pkcs5_keyivgen` with MD5→SHA256 fallback) and V2 (AES-256-GCM with PBKDF2-derived key/iv/AAD + auth tag) decryption. -- `BuildConfiguration.py:103-118`'s `decrypt_codesigning_directory_recursively` shells out via `os.system('ruby build-system/decrypt.rb …')` per file. -- `build-system/Make/DecryptMatch.py` already exists as an aspirational Python port but is broken — its V2 implementation writes a literal placeholder string (`b"TEST_DECRYPTED_CONTENT"`) and the call site in `BuildConfiguration.py:115` is commented out. -- The production fastlane repo at `git@gitlab.com:peter-iakovlev/fastlanematch.git` stores files in V2 format (verified: base64 prefix decodes to `match_encrypted_v2__`). V2 must work. - -## Constraints - -- Stock macOS `python3` (3.9.6). Only Python stdlib may be used (`hashlib`, `hmac`, `base64`, `os`). -- Apple-shipped `openssl enc` CLI rules out the shell-out path for V2 because it does not accept AAD for GCM. -- The Ruby script's semantics are authoritative; the port must be byte-identical on the existing repo contents. - -## Approach - -Rewrite `build-system/Make/DecryptMatch.py` from scratch as a pure-Python AES implementation. - -**AES-256 primitive.** Standard tables-based implementation: -- `_SBOX` / `_INV_SBOX` (256 bytes each), `_RCON` (10 bytes). -- `_key_expansion(key)` → 15 × 16-byte round keys (Nk=8, Nr=14, Nb=4 for AES-256). -- `_aes_encrypt_block(block, rks)` and `_aes_decrypt_block(block, rks)` operating on 16-byte state via SubBytes / ShiftRows / MixColumns (and their inverses) plus AddRoundKey. -- MixColumns via the standard `xtime`-based GF(2^8) multiply. - -**V1 — AES-256-CBC with OpenSSL's `EVP_BytesToKey`.** Ruby's `pkcs5_keyivgen(password, salt, 1, hash)` is `EVP_BytesToKey` with `count=1`: - -``` -D_0 = empty -D_i = hash(D_{i-1} || password || salt) # no inner iteration when count=1 -material = D_1 || D_2 || ... # until ≥ 48 bytes -key = material[0:32]; iv = material[32:48] -``` - -CBC decrypt: per 16-byte block, inverse-cipher then XOR with previous ciphertext block (seed = `iv`). Strip PKCS#7 padding at the end (validate `1 ≤ pad ≤ 16` and all pad bytes equal). Try `md5` first; on failure (non-PKCS#7 tail or downstream error), retry with `sha256`, mirroring the Ruby `rescue` fallback. - -**V2 — AES-256-GCM with PBKDF2-derived key + IV + AAD.** Key schedule matches Ruby exactly: - -``` -material = hashlib.pbkdf2_hmac('sha256', password, salt, 10_000, dklen=32+12+24) -key = material[0:32]; iv = material[32:44]; aad = material[44:68] -``` - -GCM decrypt (IV is 96-bit, the common case): -- `H = AES_encrypt(key, 0^128)` (GHASH subkey) -- `J0 = iv || 0x00000001` -- Stream the ciphertext via CTR starting from `inc32(J0)`; counter is the low 32 bits of the block, rolled over mod 2^32. -- `GHASH(H, aad, ciphertext)` = fold AAD (zero-padded to 16), then ciphertext (zero-padded to 16), then `len(aad)_64 || len(ct)_64` bits, via GF(2^128) multiplication with reduction polynomial `0xe1…00`. -- `T = GHASH output XOR AES_encrypt(key, J0)`; raise if `T != auth_tag`. - -GF(2^128) multiply is the standard right-shift-with-conditional-reduce loop (per-bit; fine for the kilobytes-at-most we're decrypting). - -**File I/O.** The fastlane match file is ASCII base64 (confirmed on the live repo). Read as text, strip whitespace, base64-decode, dispatch on the 20-byte V2 magic prefix vs. the 8-byte `Salted__` V1 prefix. Replace the text-vs-binary heuristic in the current broken implementation — that heuristic was wrong and is unnecessary. - -**Public API.** Keep `decrypt_match_data(source_path, destination_path, password)` signature so `BuildConfiguration.py` can swap the shell-out for a direct call with a one-line change. - -## Changes - -1. **Rewrite `build-system/Make/DecryptMatch.py`** end to end: AES primitives, `EVP_BytesToKey`, CBC decrypt, GCM decrypt, MatchDataEncryption dispatch, `decrypt_match_data` entry point. Drop the `subprocess`/`tempfile` and placeholder-V2 code paths entirely. -2. **Flip `BuildConfiguration.py:103-118`** — replace the `os.system('ruby build-system/decrypt.rb …')` call with `decrypt_match_data(source_path, destination_path, password)`. Remove the dead commented line. -3. **Delete `build-system/decrypt.rb`**. - -## Verification - -Run the user-supplied command: - -``` -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/build/telegram/telegram-bazel-cache \ - generateProject \ - --configurationPath ~/build/telegram/telegram-internal-tools/PrivateData/build-configurations/enterprise-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent -``` - -Success criteria: `generateProject` completes, the `decrypted/profiles/development/*.mobileprovision` files are valid plists parseable by `openssl smime` (which `copy_profiles_from_directory` does immediately after, so any decryption corruption would surface there), and the generated Xcode project has correct signing settings. - -Cross-check during development: decrypt one sample file with both the old Ruby script and the new Python and compare `sha256sum`s byte-for-byte before running the full command. - -## Non-goals - -- V1 with salt-less files (the fastlane "no salt" format variant): the Ruby script doesn't handle it either. -- GCM with non-96-bit IV: PBKDF2 derivation fixes IV length at 12 bytes, so this case cannot arise. -- Streaming decryption for huge files: match files are at most a few MB. -- AES-128 / AES-192: unused by fastlane match. diff --git a/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md b/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md deleted file mode 100644 index f94aec40b4..0000000000 --- a/docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md +++ /dev/null @@ -1,195 +0,0 @@ -# SwiftTL — Optional Layered Schema Generation - -**Date:** 2026-04-21 -**Tool:** `build-system/SwiftTL` -**Inputs this unblocks:** `telegram-ios-shared/tools/secret_scheme.tl`, invoked by `telegram-ios-shared/tools/generate_and_copy_scheme.sh` with `--api-prefix=SecretApi`. -**Consumers this targets:** `submodules/TelegramCore/Sources/State/ManagedSecretChatOutgoingOperations.swift`, `submodules/TelegramCore/Sources/State/ProcessSecretChatIncomingDecryptedOperations.swift` — both reference `SecretApi{8,46,73,101,144}..`, symbols currently provided by hand-maintained `submodules/TelegramApi/Sources/SecretApiLayer{8,46,73,101,144}.swift` files. - -## Problem - -`SwiftTL` parses a flat `.tl` schema and emits one flat `Api` namespace. `secret_scheme.tl` is not flat — it's a multi-version schema separated by `===N===` layer markers (11 layers: 8, 17, 20, 23, 45, 46, 66, 73, 101, 143, 144), where the same constructor name can reappear in later layers with a new constructor ID and new fields (e.g. `decryptedMessage` exists at layers 8, 17, 45, 73, each with a different ID and argument list). - -Running `SwiftTL secret_scheme.tl … --api-prefix=SecretApi` today fails: `DescriptionParser` doesn't recognize `===N===` markers, and `Resolver` throws on the first duplicate constructor name. The secret-chat `SecretApi{N}..` structs that downstream code already uses are hand-maintained and out-of-sync with what SwiftTL would naturally produce. - -## Goal - -Extend `SwiftTL` with optional layered-schema support so that `secret_scheme.tl` round-trips through the same CLI: one invocation produces one Swift file per declared layer. Flat schemas (`swift_scheme.tl`) continue to produce byte-identical output. - -Non-goal: a complete rewrite of the legacy hand-written `SecretApiLayer*.swift` format. Output is "close enough" — same sum-type enums, same constructor IDs, same serialize/parse bodies — not byte-for-byte identical to the legacy files. Existing consumers compile unchanged because they reference the public symbols (`SecretApi8.DecryptedMessage.decryptedMessage(...)`), which the generator preserves. - -## Architecture - -Four files change in `build-system/SwiftTL/Sources/SwiftTL/`. No new files, no new CLI flags. - -### `DescriptionParsing.swift` - -The public `parse(data:)` return type changes from a tuple `(constructors, functions)` to a new enum: - -```swift -enum ParsedSchema { - case flat(constructors: [ConstructorDescription], functions: [ConstructorDescription]) - case layered(layers: [(layerNumber: Int, constructors: [ConstructorDescription])]) -} -``` - -**Detection rule.** If any non-empty line matches the regex `^===\d+===\s*$`, the schema is layered. Every non-skipped constructor must sit under a marker; constructors appearing before the first marker are attached to the lowest-numbered layer. Otherwise the schema is flat (today's behavior, unchanged). - -**Input validation** (only enforced in the layered branch): -- Layer numbers must be positive integers and appear in strictly ascending order in the source. Parser throws otherwise. -- `---functions---` is forbidden in layered mode. Parser throws if seen. -- Empty layers (marker followed immediately by the next marker or EOF) are allowed. They produce an output file whose cumulative snapshot is identical to the previous layer's. - -The existing `skipPrefixes` / `skipContains` filter (for `true`, `vector`, `error`, `null`, `{X:Type}`) applies unchanged to both branches. - -### `Resolution.swift` - -A new static method on `Resolver`: - -```swift -static func resolveLayeredTypes( - layers: [(layerNumber: Int, constructors: [DescriptionParser.ConstructorDescription])] -) throws -> [(layerNumber: Int, types: [SumType])] -``` - -Algorithm — walks layers in input order, maintaining a running map `constructorsByName: [QualifiedName: (typeName: QualifiedName, constructor: DescriptionParser.ConstructorDescription)]`. For each layer: - -1. For each constructor in the layer: if the name already exists in the running map with a different target type, remove it from the old type's entry before inserting under the new target type. -2. Insert or overwrite the constructor in the running map. -3. At the end of the layer section, build `[SumType]` from the current running map by grouping constructors by their target type and resolving argument type references (same machinery `resolveTypes(constructors:)` already uses, factored into shared helpers). - -The output preserves per-layer IDs: layer 8's `decryptedMessage` has ID `0x1f814f1f`, layer 17's has `0x204d3878`, layer 46's has `0x36b091de`, layer 73's has `0x91cc4674` — each landing in its own independent `[SumType]` snapshot. - -The existing `resolveTypes(constructors:)` and `resolveFunctions(…)` stay unchanged for the flat path. - -### `CodeGeneration.swift` - -A new static method on `CodeGenerator`: - -```swift -static func generateLayered( - apiPrefix: String, - layerNumber: Int, - types: [SumType] -) throws -> (filename: String, source: String) -``` - -Returns filename `"\(apiPrefix)Layer\(layerNumber).swift"` and a source string in the shape described below. Reuses the existing private helpers `typeReferenceRepresentation`, `generateFieldSerialization`, `generateFieldParsing`, and `SumType.hasDirectReference(to:typeMap:)` unchanged — the per-argument serialize/parse logic is byte-identical between flat and layered output. - -The flat `CodeGenerator.generate(…)` entry point is untouched. - -### `main.swift` - -Branches on the parser's return value: - -```swift -switch try DescriptionParser.parse(data: data) { -case let .flat(constructors, functions): - // existing flow, unchanged -case let .layered(layers): - let resolved = try Resolver.resolveLayeredTypes(layers: layers) - try FileManager.default.createDirectory( - at: URL(fileURLWithPath: outputDirectoryPath), - withIntermediateDirectories: true) - for (layerNumber, types) in resolved { - let (filename, source) = try CodeGenerator.generateLayered( - apiPrefix: apiPrefix, layerNumber: layerNumber, types: types) - let filePath = URL(fileURLWithPath: outputDirectoryPath) - .appendingPathComponent(filename).path - _ = try? FileManager.default.removeItem(atPath: filePath) - try source.write(toFile: filePath, atomically: true, encoding: .utf8) - } -} -``` - -## Layer semantics - -For each emitted layer `N`, the effective constructor set is the ordered union of all constructors declared in layers `L ≤ N`, where a constructor with a given `QualifiedName` in a later layer **replaces** the earlier entry (new ID, new arguments, potentially new target sum type). The latest winner is the only one that appears in layer `N`'s output; earlier IDs are not included in layer `N`'s dispatch table. - -Constructors declared only in layers `> N` do not appear in layer `N`. - -Pre-marker constructors (e.g. `boolFalse`, `boolTrue` in `secret_scheme.tl`) are attached to the lowest-numbered layer. Rationale: (1) keeps the rule uniform ("every constructor belongs to exactly one declared layer"), (2) matches the natural reading of the schema file, (3) has no observable effect today since no downstream consumer references `Bool` from a secret-schema layer. - -## Output format (per layer) - -Matches the shape of the existing hand-written `SecretApiLayer{N}.swift` files. One file per layer, named `{apiPrefix}Layer{N}.swift`. - -``` - - -fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = { - var dict: [Int32 : (BufferReader) -> Any?] = [:] - dict[-1471112230] = { return $0.readInt32() } - dict[570911930] = { return $0.readInt64() } - dict[571523412] = { return $0.readDouble() } - dict[-1255641564] = { return parseString($0) } - // dict[0x0929C32F] = { return parseInt256($0) } — emitted iff any constructor - // in this layer's cumulative snapshot has a field of type Int256. - dict[] = { return {apiPrefix}{N}..parse_($0) } - // ... one entry per (latest) constructor in the cumulative snapshot - return dict -}() - -public struct {apiPrefix}{N} { - public static func parse(_ buffer: Buffer) -> Any? { ... } - fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any? { ... } - fileprivate static func parseVector(_ reader: BufferReader, elementSignature: Int32, elementType: T.Type) -> [T]? { ... } - public static func serializeObject(_ object: Any, buffer: Buffer, boxed: Swift.Bool) { ... } - - public enum { /* cases, serialize, parse_* */ } - public enum { ... } - // ... -} -``` - -**Deliberate differences from the flat-mode `Api0/1/….swift` output:** - -- Single file instead of `Api0` header + `Api{1..N}` sharded impl files. -- `public struct` for the namespace instead of `public enum`. -- Nested `public enum ` declarations instead of extensions. -- No `Cons_*` helper classes; enum cases use the inline-args shape — i.e. `case decryptedMessage(randomId: Int64, randomBytes: Buffer, message: String, media: …)`. Note the flat generator has a dormant inline-args branch guarded by `useStructPattern = false` that is never taken today; the layered generator renders this shape directly rather than sharing that branch. -- No `descriptionFields()` method, no `TypeConstructorDescription` conformance on the enums. -- `parse_*` methods are `fileprivate`, not `public static`. -- No `---functions---` section (rejected upstream). - -The `indirect` keyword is still emitted when a type transitively references itself, via the existing `SumType.hasDirectReference(to:typeMap:)` helper. - -## CLI - -Unchanged. `swift run SwiftTL [--api-prefix=]`. Layered behavior auto-triggers on `===N===` marker presence. With `--api-prefix=SecretApi` on `secret_scheme.tl`, SwiftTL emits 11 files: `SecretApiLayer{8,17,20,23,45,46,66,73,101,143,144}.swift`. - -## Out-of-scope follow-ups - -### `generate_and_copy_scheme.sh` - -Lives in `telegram-ios-shared/tools/` (sibling repo). Currently invokes SwiftTL on both schemas but only copies `NewScheme/Api*.swift` into `submodules/TelegramApi/Sources/`. After this SwiftTL change lands, the script gains: - -```sh -rm -f ../../telegram-ios/submodules/TelegramApi/Sources/SecretApiLayer*.swift -cp NewSecretScheme/SecretApiLayer*.swift ../../telegram-ios/submodules/TelegramApi/Sources/ -``` - -The SwiftTL change produces the right files; the shell-script wiring is a follow-up commit in the sibling repo. - -### `submodules/TelegramApi/BUILD` - -If `submodules/TelegramApi/BUILD` lists the existing `SecretApiLayer{8,46,73,101,144}.swift` explicitly, it must be updated to include the 6 new layer files (17, 20, 23, 45, 66, 143) before the project will build. Implementation step: grep BUILD for `SecretApiLayer` at the start of implementation — if explicit, either add the 6 new file entries or switch to a `glob(["Sources/SecretApiLayer*.swift"])` pattern, in the same commit that introduces the files. - -## Verification - -No unit tests exist in this repo (per `CLAUDE.md`). Verification steps: - -1. **Layered schema compiles.** `swift run SwiftTL /secret_scheme.tl /tmp/out --api-prefix=SecretApi` succeeds and produces 11 files. -2. **Generated files match legacy by semantics.** Spot-check `SecretApiLayer8.swift`, `SecretApiLayer46.swift`, `SecretApiLayer73.swift`, `SecretApiLayer101.swift`, `SecretApiLayer144.swift` against their hand-written counterparts in `submodules/TelegramApi/Sources/`. Confirm: - - Same set of enum case names per sum type. - - Same constructor IDs in the dispatch table (latest per name only). - - Same argument ordering and types. - - Same indirect-ness for self-referential types. - Cosmetic differences (whitespace, per-helper indentation quirks, absence of `Cons_*`) are acceptable. -3. **Project builds.** Copy the generated files over the hand-written ones in `submodules/TelegramApi/Sources/`, run the full Bazel build (`source ~/.zshrc 2>/dev/null; Make.py build --continueOnError`), and confirm zero compilation errors. `ManagedSecretChatOutgoingOperations.swift` and `ProcessSecretChatIncomingDecryptedOperations.swift` reference `SecretApi{8,46,73,101,144}..` symbols that the generator preserves. -4. **Flat schema is unchanged.** `swift run SwiftTL /swift_scheme.tl /tmp/out-main` succeeds; diff the generated `Api*.swift` against `submodules/TelegramApi/Sources/Api*.swift`. Expected: byte-identical (flat codepath untouched). - -## Risks - -- **Legacy-file semantic drift.** The hand-written `SecretApiLayer*.swift` files may contain micro-deviations from what the schema strictly implies (a constructor sneaked in by hand, an ID typo, an argument order tweak). Any such deviations will surface as compile or runtime-parse errors after regeneration. Mitigation: verification step 2 surfaces these before building; if found, the spec takes the schema as authoritative — legacy hand-edits get reverted, not preserved. -- **BUILD glob vs. explicit file list.** If BUILD lists files explicitly, adding the 6 new layer files (17, 20, 23, 45, 66, 143) requires a BUILD update in the same commit. Verification step during implementation. -- **Pre-marker constructor attribution.** `boolFalse`/`boolTrue` land in layer 8 under the spec. If the existing hand-written `SecretApiLayer8.swift` does not contain `Bool` (likely, since no consumer references it), the generator will add a nested `public enum Bool { case boolFalse; case boolTrue }` to layer-8 (and cumulatively to every subsequent layer) and two entries to each cumulative layer's dispatch dict. Harmless addition — build unaffected; diff noise only. diff --git a/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md b/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md deleted file mode 100644 index 52336bab10..0000000000 --- a/docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md +++ /dev/null @@ -1,137 +0,0 @@ -# TextStyleEditScreen caret-tracking auto-scroll — design - -## Background - -`submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` hosts a sheet built on `ResizableSheetComponent` with two `ListMultilineTextFieldItemComponent` fields (a title and a multi-line prompt). The sheet's `inputHeight` plumbing and scroll-content sizing have already been wired up: - -- `TextStyleEditSheetComponent.View.update` passes `environmentValue.inputHeight` into `ResizableSheetComponentEnvironment(inputHeight:)` instead of a hardcoded `0.0`. -- `ResizableSheetComponent` now subtracts `inputHeight` from `topInset` and adds it to `scrollContentHeight` so the scroll view has enough room to pan past the keyboard. - -What remains: when the user types, the caret in the focused field must be scrolled into the visible area above the soft keyboard. Without this, typing near the bottom of the prompt field hides the caret under the keyboard. - -## Goal - -Whenever a text edit occurs in either `ListMultilineTextFieldItemComponent` inside `TextStyleEditContentComponent`, adjust the enclosing scroll view's `bounds.origin.y` so that the caret rect sits comfortably above the keyboard and bottom action button. - -## Scope - -All changes live in `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`. No changes to `ResizableSheetComponent`, `ListMultilineTextFieldItemComponent`, or `TextFieldComponent` — their existing public surfaces are sufficient: - -- `ListMultilineTextFieldItemComponent.Tag` (constructor param `tag:`, plus `matches(tag:)` on the view). -- `ListMultilineTextFieldItemComponent.View.textFieldView: TextFieldComponent.View?`. -- `TextFieldComponent.View.inputTextView: UITextView`. -- `TextFieldComponent.AnimationHint` attached as `userData` on the transition whenever `TextFieldComponent` fires a state update on text change (`TextFieldComponent.swift:471/491/504/542/620/1077`). - -## Non-goals - -- No scroll on focus change alone (user requirement — text-change only). -- No scroll on selection change without an edit. -- No scroll-triggering on keyboard show/hide independently of text changes. -- No changes to shared infrastructure (`ResizableSheetComponent` stays as-is after the user's sizing work). - -## Design - -### 1. Field tagging - -`TextStyleEditContentComponent.View` stores two tags created once at init: - -```swift -private let titleFieldTag = ListMultilineTextFieldItemComponent.Tag() -private let textFieldTag = ListMultilineTextFieldItemComponent.Tag() -``` - -The two `ListMultilineTextFieldItemComponent(...)` constructions in `update(...)` pass the corresponding tag via the existing `tag:` parameter (currently `tag: nil` at both sites). This lets us identify which of our fields a hint's originating `TextFieldComponent.View` belongs to. - -### 2. Recenter trigger - -At the end of `TextStyleEditContentComponent.View.update(...)`, after all sub-component layout is complete and all frames are set, evaluate the incoming transition's user data: - -```swift -if let hint = transition.userData(TextFieldComponent.AnimationHint.self), - case .textChanged = hint.kind, - let hintView = hint.view { - self.recenterCaret(hintView: hintView, availableSize: availableSize, environment: environment, transition: transition) -} -``` - -`hint.kind` is either `.textChanged` or `.textFocusChanged(isFocused:)`; we match only `.textChanged`. - -### 3. Scroll-to-caret logic - -`recenterCaret(hintView:availableSize:environment:transition:)` is a private method on `TextStyleEditContentComponent.View` that performs these steps: - -1. **Locate field view.** Walk ancestors of `hintView` up to the first `ListMultilineTextFieldItemComponent.View`. Confirm it matches one of `self.titleFieldTag` / `self.textFieldTag` via `fieldView.matches(tag:)`. If neither matches, bail silently. - -2. **Compute caret rect in text-view space.** From the field view, grab `textFieldView?.inputTextView`. Retrieve the caret rect: - ```swift - let endPosition = inputTextView.selectedTextRange?.end ?? inputTextView.endOfDocument - let caretRect = inputTextView.caretRect(for: endPosition) - ``` - If `caretRect.isNull` or `caretRect.isInfinite`, bail (text view hasn't laid out yet). - -3. **Locate enclosing scroll view.** Walk `self.superview` chain until the first `UIScrollView` is found (this is `ResizableSheetComponent`'s private scroll view). If no scroll view is found, bail. - -4. **Convert caret rect to scroll-view coordinates.** - ```swift - let caretInScroll = inputTextView.convert(caretRect, to: scrollView) - ``` - -5. **Compute visible region.** Within the scroll view's current bounds, determine the vertical range in which the caret should sit: - ```swift - let bottomActionAreaHeight: CGFloat = 52.0 + 8.0 // matches ResizableSheetComponent bottom layout - let caretTopInset: CGFloat = 24.0 // small cushion below keyboard/button - let caretBottomInset: CGFloat = 24.0 // small cushion above keyboard/button - let visibleTop = scrollView.bounds.minY + caretTopInset - let visibleBottom = scrollView.bounds.maxY - environment.inputHeight - bottomActionAreaHeight - caretBottomInset - ``` - -6. **Adjust `bounds.origin.y`.** Using the direct-assign + additive-animate pattern proven in `ComposePollScreen.swift:2873-2895`: - ```swift - let previousBounds = scrollView.bounds - var newBounds = previousBounds - if caretInScroll.maxY > visibleBottom { - newBounds.origin.y += (caretInScroll.maxY - visibleBottom) - } else if caretInScroll.minY < visibleTop { - newBounds.origin.y -= (visibleTop - caretInScroll.minY) - } - let maxOriginY = max(0.0, scrollView.contentSize.height - scrollView.bounds.height) - newBounds.origin.y = min(max(0.0, newBounds.origin.y), maxOriginY) - if newBounds != previousBounds { - scrollView.bounds = newBounds - if !transition.animation.isImmediate { - let offsetY = previousBounds.origin.y - newBounds.origin.y - transition.animateBoundsOrigin(view: scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) - } - } - ``` - This keeps the scroll animation in sync with the text-change spring carried by the hint's transition, and matches existing precedent in the codebase. - -## Edge cases - -- **Caret rect unavailable.** `caretRect(for:)` returns `CGRect.null` or `CGRect.infinite` when the text view hasn't laid out. Skip — the next hint will cover it. -- **No enclosing scroll view.** Defensive bail; should never happen in normal operation but keeps the code robust against host refactors. -- **Hint from unrelated field.** Tag mismatch → bail. Keeps the scroll view untouched if a future nested text input is added. -- **Over/under-scroll.** `newBounds.origin.y` clamped to `[0, contentSize.height − bounds.height]`. -- **Caret already visible.** No-op — `newBounds != scrollView.bounds` guards against churn. - -## File-level changes summary - -Only `TextStyleEditScreen.swift` is edited: - -- Add two stored `ListMultilineTextFieldItemComponent.Tag` properties on `TextStyleEditContentComponent.View`. -- Pass those tags into the existing two `ListMultilineTextFieldItemComponent(...)` calls in `update(...)`. -- Add a private `recenterCaret(...)` method on `TextStyleEditContentComponent.View`. -- Add a small block at the end of `update(...)` that reads `transition.userData(TextFieldComponent.AnimationHint.self)` and invokes `recenterCaret` when `.textChanged`. - -Estimated diff size: ~40–60 lines added, no deletions. - -## Verification - -No unit tests exist in this project (per `CLAUDE.md`). Verification is a full `Make.py build` plus a manual smoke test: - -1. Open `TextStyleEditScreen` in create mode on a simulator/device. -2. Tap the "Style Name" field. Confirm keyboard slides up and the "Create" button sits above the keyboard (pre-existing behavior from the user's `inputHeight` work). -3. Type a character — with short content no scroll should occur; the scroll view remains at origin zero. -4. Tap the "Instructions" field. Type enough text to push the field past the viewport. Confirm the caret stays ~24pt above the keyboard/button as each newline is added. -5. Scroll up manually to push the active field off-screen, then type one character — confirm the scroll view snaps back so the caret sits above the keyboard. -6. In edit mode on a long pre-populated prompt, tap in the middle of the prompt (no scroll expected per non-goals), then type one character — confirm the caret's line is pulled into view. diff --git a/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md b/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md deleted file mode 100644 index 3cf7a3576f..0000000000 --- a/docs/superpowers/specs/2026-04-22-claude-md-reorganization-design.md +++ /dev/null @@ -1,104 +0,0 @@ -# CLAUDE.md reorganization — design - -**Date:** 2026-04-22 -**Status:** approved (brainstorm), pending plan - -## Problem - -`CLAUDE.md` has grown to 804 lines / ~99KB. It is loaded into every AI session in this repository, so its size directly consumes context budget that could be used for actual code work. It is also hard to navigate and maintain — the bulk is a per-wave changelog of the Postbox → TelegramEngine refactor, which obscures the rules, cheat sheets, and patterns that future waves actually need. - -Two goals, weighted equally: - -1. **Reduce always-loaded context size.** Target: CLAUDE.md shrinks to roughly ~200 lines / ~20KB (an ~80% reduction). -2. **Improve discoverability.** What remains in CLAUDE.md should be tight enough that an AI assistant can scan it and find the applicable rule or pattern without wading through narrative history. - -## Current content breakdown - -- Build / Code Style / Project Structure: ~35 lines — pure guidance, stays. -- Postbox refactor section: ~750 lines, further split: - - Standing rules 1–7: ~20 lines — active rules. - - Engine typealias cheat sheet: ~25 lines — active reference. - - MediaResource → EngineMediaResource patterns: ~30 lines — active patterns. - - Wave-selection guidance: ~150 lines — distilled lessons mixed with narrative backstory. - - Wave 1–26 outcomes: ~500 lines — history. - - Running tally of Postbox-free modules: ~30 lines — changelog-style enumeration. - - TelegramEngine.Resources facade inventory table: ~30 lines — active reference table. - - Known future-wave candidates: ~40 lines — planning state, duplicates memory file. - - Build command pointer: ~2 lines — duplicate of top-of-file section. - -## Final structure - -### CLAUDE.md (stays; slimmed) - -Sections, in order: - -1. **Build** — unchanged. -2. **Code Style Guidelines** — unchanged. -3. **Project Structure** — unchanged. -4. **Postbox → TelegramEngine refactor (in progress)**, containing: - - A brief intro paragraph plus a pointer: "Wave-by-wave history, full narrative lessons, running tallies, and example scripts live in `docs/superpowers/postbox-refactor-log.md` — read that file when you need wave-specific context or a full worked example." - - **Standing rules 1–7** — unchanged. - - **Engine typealias cheat sheet** — unchanged. - - **MediaResource → EngineMediaResource consumer migration** — unchanged. - - **Wave-selection guidance** — trimmed. Keep rules and recipes as terse bullets; drop narrative backstory, wave-specific iteration counts, full example scripts. Cross-reference the log file for backstory. Target: ~40–60 lines instead of ~150. - - **TelegramEngine.Resources facade inventory table** — unchanged (active reference table). - - The duplicate "Build command" pointer at the end is dropped (already covered at the top). - -Everything that gets removed either moves to the log file or (for future-wave candidates) merges into the existing memory file. - -### `docs/superpowers/postbox-refactor-log.md` (new file, not loaded by default) - -- Short header explaining purpose: "Historical record of the Postbox → TelegramEngine refactor. Not loaded by default into AI sessions. AI assistants should read this file when they need wave-specific context, full worked examples of a pattern, or the running tally of module Postbox-freeness." -- Wave 1–26 outcomes verbatim (no edits). -- Running tally of Postbox-free modules. -- Full self-contained forms of each guidance subsection that gets trimmed in CLAUDE.md — the rule, the backstory, example scripts, iteration-count stories, and pre-migration inventories together, so a reader of the log file doesn't need to jump back to CLAUDE.md to know what rule the backstory supports. Each subsection has a stable anchor that the trimmed CLAUDE.md bullet can cross-reference. - -### `project_postbox_refactor_next_wave.md` (existing memory file; updated) - -- Merge in the four categories from CLAUDE.md's "Known future-wave candidates" section: - - Permanently blocked (4 classes conforming to `TelegramMediaResource`). - - Higher-friction mediaBox methods (cached representations, resourceData/resourceStatus sweeps, storageBox wrapping). - - Non-mediaBox established patterns (preferencesView sweep, `loadedPeerWithId` sweep). - - Standalone Postbox-class-move opportunities. - - Unused-import sweep re-run. -- Keep existing wave-27+ shortlist content. - -## What "trim the guidance" concretely means - -For each subsection under "Wave-selection guidance", the rule is **keep the actionable rule/recipe; drop the story**. - -Worked example — current "Unused-import sweeps are a valid wave shape" is a ~35-line block with numbered methodology (steps 1–7), a script snippet, and an iteration-count anecdote ("18 → 4 → 5 → 3 → 12 → ..."). After trim in CLAUDE.md: - -> **Unused-import sweeps** (wave-shape applied in waves 6, 14): speculatively drop `^import Postbox$`, build with `--continueOnError`, restore failures, iterate. After a few iterations, do pattern-based preemptive restores for files naming Postbox-only symbols. Scope never leaves the consumer-module candidate set — halt if errors surface in TelegramCore/Postbox/TelegramApi. Full methodology, scripts, and iteration stats in `postbox-refactor-log.md`. - -Same treatment for the other guidance subsections: - -- "Wave-selection guidance" (top-level "leaf module, drop Postbox in isolation" commentary) -- "Two feasible wave shapes" paragraph -- "Enum-payload migrations need a full case-site grep" paragraph -- "Public-Postbox-type inventory (wave-11-pattern planning)" paragraph — including the `postbox-public-types.txt` script -- "Wave-shape G: facade addition + consumer sweep in one commit" paragraph — keep the seven-step recipe, drop prose about wave-26 `RangeSet` example - -## Implementation approach - -Three commits, each self-contained: - -1. **Create the log file.** Write `docs/superpowers/postbox-refactor-log.md` containing: header, Wave 1–26 outcomes verbatim, running tally of Postbox-free modules, verbose guidance passages extracted from CLAUDE.md. Commit. -2. **Rewrite CLAUDE.md.** Trim the guidance section to terse bullets with log-file cross-references, drop the wave outcomes and running tally sections, drop the duplicate build-command pointer at the bottom, add the log-file pointer near the start of the Postbox section. Commit. -3. **Update memory files.** Merge "Known future-wave candidates" into `project_postbox_refactor_next_wave.md`. Update `MEMORY.md` one-line index if its description of that file changes materially. Commit. - -Commits are ordered so that if anyone reads HEAD at any point between commits, nothing is lost: commit 1 adds content without removing any, commit 2 removes content that's now in the log, commit 3 moves planning state to where it belongs. - -## Non-goals - -- No pruning or editing of the wave outcomes themselves. Verbatim move. -- No restructuring of the rest of `docs/` or of the `memory/` directory beyond the one-section merge. -- No changes to the build, code style, or project structure sections of CLAUDE.md. - -## Success criteria - -- CLAUDE.md ≤ ~250 lines / ~25KB. (Hard cap; stretch target ~200 lines / ~20KB.) -- Every guidance bullet in the trimmed CLAUDE.md either stands alone or has an explicit cross-reference to `postbox-refactor-log.md`. -- `postbox-refactor-log.md` contains Wave 1–26 outcomes verbatim — a diff between the removed-from-CLAUDE.md text and the added-to-log text should be empty. -- `project_postbox_refactor_next_wave.md` contains all five categories of future-wave candidates that previously lived in CLAUDE.md. -- No information is lost across the three commits. diff --git a/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md deleted file mode 100644 index 9c8b8a3c6d..0000000000 --- a/docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md +++ /dev/null @@ -1,227 +0,0 @@ -# Wave 36 — `ContactListPeer.peer` `Peer` → `EnginePeer` - -Date: 2026-04-24 -Status: approved design, awaiting plan -Wave shape: Peer-typed-API enum-case payload migration, single atomic commit (waves 34/35 pattern) - -## Goal - -Eliminate the Postbox-protocol `Peer` leak in the `ContactListPeer.peer(peer:isGlobal:participantCount:)` case payload by migrating the `peer` field from `Peer` to `EnginePeer`. Drop the outflow `._asPeer()` bridges that waves 33/34 installed at construction sites, and the inflow `EnginePeer(...)` wrappings at destructure sites. Apply wave 35's validated pre-flight pattern set (literal token + `.peer as?`/`is` + outflow-args + `EnginePeer(.peer)` + `._asPeer()`) to keep undercount below wave 35's 14%. - -## Non-goals - -- `ContactListPeerId.peer(PeerId)` (sibling enum, different payload) — unchanged; `PeerId == EnginePeer.Id` makes it already-clean. -- `canSendMessagesToPeer(_ peer: Peer, ignoreDefault: Bool) -> Bool` parameter migration — broader blast radius, deferred. -- `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` protocol-method migrations — broader blast radius, deferred. -- `openPeer(peer: Peer, ...)` / other Peer-typed APIs called from destructured bodies — if any destructured `peer` outflows into a raw-`Peer`-typed API after migration, add a `._asPeer()` bridge at that call site. Migrating those APIs is its own future wave. -- No new engine wrappers, typealiases, or facades introduced in this wave. -- No `import Postbox` drops in this wave — deferred to a follow-on unused-import sweep. - -## Type change - -```swift -// Before -public enum ContactListPeer: Equatable { - case peer(peer: Peer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { … } - public var indexName: PeerIndexNameRepresentation { … } - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, - lhsPeer.isEqual(rhsPeer), // Postbox protocol method - lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { return false } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { return true } else { return false } - } - } -} - -// After -public enum ContactListPeer: Equatable { - case peer(peer: EnginePeer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { … } // body unchanged; peer.id is EnginePeer.Id == PeerId - public var indexName: EnginePeer.IndexName { … } // return type changed — body unchanged but type flows from EnginePeer.indexName - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, - lhsPeer == rhsPeer, // EnginePeer is Equatable - lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { return false } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { return true } else { return false } - } - } -} -``` - -The custom `==` is retained (rather than relying on synthesis) because `DeviceContactStableId` / `DeviceContactBasicData` conformance to Equatable is not verified here; minimising unrelated change. Only the `lhsPeer.isEqual(rhsPeer)` clause is rewritten. - -## In-scope files - -Scope based on the pre-flight Explore inventory plus a manual deep-scan pass that caught additional inflow wraps and Postbox-concrete casts the Explore agent missed. One definition file plus nine consumer files; seven of the consumer files need edits. Two (ComposeController, ChatSendAudioMessageContextPreview) have only `.id`-level accesses and should need no body change — plan verifies each during implementation. - -### Category α — Definition (`AccountContext`) - -**`submodules/AccountContext/Sources/ContactSelectionController.swift`** -- Line 62: enum case signature change `peer: Peer` → `peer: EnginePeer`. -- Line 74: computed property return type change `PeerIndexNameRepresentation` → `EnginePeer.IndexName`. Rationale: after the payload migration, `peer.indexName` at line 77 returns `EnginePeer.IndexName` (from `EnginePeer.indexName`), not `PeerIndexNameRepresentation`. Changing the return type up rather than re-bridging via `peer._asPeer().indexName` eliminates a Postbox-typed API from AccountContext and incidentally lets two `EnginePeer.IndexName(...)` wraps at ContactListNode:517 drop. The two enum case shapes match exactly — `EnginePeer.IndexName.title(title:addressNames:)` and `EnginePeer.IndexName.personName(first:last:addressNames:phoneNumber:)` are defined at `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:145-147` with the same parameter labels and types as `PeerIndexNameRepresentation`'s cases. -- Line 77: `return peer.indexName` — body unchanged; type now flows `EnginePeer → EnginePeer.IndexName`. -- Line 79: `return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "")` — body unchanged; case resolution retargets to `EnginePeer.IndexName.personName`. -- Line 86: `==` operator — rewrite `lhsPeer.isEqual(rhsPeer)` to `lhsPeer == rhsPeer`. -- Line 67: `peer.id` same-type access (EnginePeer.id returns EnginePeer.Id ≡ PeerId) — unchanged. - -### Category β — Outflow-bridge drops (the dominant pattern) - -Every site below is `.peer(peer: ._asPeer(), isGlobal: …, participantCount: …)` → `.peer(peer: , …)`, because `` is already `EnginePeer` at the call site. - -**`submodules/ContactListUI/Sources/ContactListNode.swift`** — 12 sites: 632, 690, 701, 747, 765, 1365, 1647, 1656, 1693, 1731, 1942, 1944. - -**`submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift`** — 3 sites: 494, 535, 569. - -**`submodules/TelegramUI/Sources/ContactMultiselectionController.swift`** — 2 bridged sites: 451, 459. - -**`submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift`** — 1 site: 317. - -**`submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift`** — 2 sites: 160, 230. - -Total: 20 outflow-bridge drops. - -### Category γ — Removed - -Earlier draft flagged `TelegramUI/ContactMultiselectionController.swift:379` as a raw-`Peer` construction needing `EnginePeer(peer)` promotion. Rechecked: line 379 is inside a destructure at line 347 (`case let .peer(peer, _, _) = peer`), so post-migration the inner `peer` is already `EnginePeer` and the existing `.peer(peer: peer, ...)` continues to compile without wrapping. No edit needed. - -### Category δ — Inflow-wrapping drops at destructure sites - -Every site is `EnginePeer(peer)` applied to a destructured peer that becomes `EnginePeer` directly post-migration → drop each wrap. - -- **ContactListNode.swift**: 4 wraps total. - - Line 204 wraps `peer` twice inside `.peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))` (inside destructure at line 177). - - Line 252 wraps once inside `interaction.openDisabledPeer(EnginePeer(peer), …)` (inside destructure at line 251). - - Line 844 wraps once inside `isPeerEnabled(EnginePeer(peer))` (inside destructure at line 833). -- **ContactsController.swift**: 1 wrap — line 294 `chatLocation: .peer(EnginePeer(peer))` where `peer` is destructured at line 287. -- **ContactsSearchContainerNode.swift**: 4 wraps total. - - Line 164 `peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer))` (2 wraps, inside destructure at line 163). - - Line 165 `nativePeer = EnginePeer(peer)` (1 wrap, same destructure). - - Line 181 `openDisabledPeer(EnginePeer(peer), …)` (1 wrap, inside destructure at line 180). -- **TelegramUI/Sources/ContactMultiselectionController.swift**: 4 wraps total. - - Line 386 `subject: .peer(EnginePeer(peer))` (inside destructure at line 347). - - Line 403 `subject: .peer(EnginePeer(peer))` (same destructure). - - Line 481 `self.params.sendMessage?(EnginePeer(peer))` (inside destructure at line 468). - - Line 491 `self.params.openProfile?(EnginePeer(peer))` (same destructure). -- **TelegramUI/Sources/ContactMultiselectionControllerNode.swift**: 1 wrap — line 492 `EnginePeer(peer).compactDisplayTitle` (inside destructure at line 491). -- **TelegramUI/Sources/ContactSelectionController.swift**: 2 wraps total. - - Line 517 `self.sendMessage?(EnginePeer(peer))` (inside destructure at line 504). - - Line 527 `self.openProfile?(EnginePeer(peer))` (same destructure). - -Total: 16 inflow-wrap drops. - -### Category φ — Postbox-concrete cast rewrites - -Destructured `peer` post-migration is `EnginePeer`. Existing `peer as? TelegramUser`/`TelegramGroup`/`TelegramChannel` casts no longer compile; rewrite to `EnginePeer` case-pattern matches. Both sites are in `ContactListNode.swift`. - -- **ContactListNode.swift:182-186** — inside destructure at line 177. Rewrite the `if let _ = peer as? TelegramUser { … } else if let group = peer as? TelegramGroup { … } else if let channel = peer as? TelegramChannel { … }` chain to `switch peer { case .user: … case let .legacyGroup(group): … case let .channel(channel): … default: break }`, or equivalently to the `if case .user = peer / if case let .legacyGroup(group) = peer / if case let .channel(channel) = peer` chain. Inner `group.participantCount`, `channel.info`, `case .group = channel.info` continue to compile unchanged because `EnginePeer.channel` / `.legacyGroup` wrap the exact same concrete types (`TelegramChannel`, `TelegramGroup`) and `.user` wraps `TelegramUser`. Note: the original `if let _ = peer as? TelegramUser` branch doesn't bind the user — rewrite keeps that (either `case .user = peer` or `if case .user = peer`). -- **ContactListNode.swift:1968** — inside destructure at line 1966. Rewrite `let user = peer as? TelegramUser` to `case let .user(user) = peer`. Inner `user.phone` continues to compile (`EnginePeer.user` wraps `TelegramUser`). - -EnginePeer enum case mapping (reference): - -| Postbox concrete | EnginePeer case | -|---|---| -| `TelegramUser` | `.user(TelegramUser)` | -| `TelegramGroup` | `.legacyGroup(TelegramGroup)` | -| `TelegramChannel` | `.channel(TelegramChannel)` | - -Lines 1802, 1818, 1820 in ContactListNode.swift also contain `peer as? TelegramChannel`/`peer is TelegramGroup` casts but these are on `peer` values sourced from `entryData.renderedPeer.peer` (raw Postbox `Peer`), not from a ContactListPeer destructure. They stay unchanged — out of wave scope. - -### Category ε′ — `ContactListPeer.indexName` return-type cascade - -Because category α changes the return type of `ContactListPeer.indexName` to `EnginePeer.IndexName`, call sites that currently wrap that return in `EnginePeer.IndexName(...)` can drop the wrap: - -- **ContactListNode.swift:517** — `let result = EnginePeer.IndexName(lhs.indexName).isLessThan(other: EnginePeer.IndexName(rhs.indexName), ordering: sortOrder)` → `let result = lhs.indexName.isLessThan(other: rhs.indexName, ordering: sortOrder)`. Two wraps drop. The `isLessThan(other:ordering:)` extension is defined on `EnginePeer.IndexName` only (see `submodules/LocalizedPeerData/Sources/PeerTitle.swift:64`), so the existing wrap idiom was required pre-migration. - -- **ContactListNode.swift:539, 590** — `switch peer.indexName` / `switch orderedPeers[i].indexName` with `case let .title(…)` and `case let .personName(…)` — continues to compile unchanged. Same case names and shapes. - -### Category ε — Same-type field access (no edit) - -Destructured peer bindings whose only uses are `.id`, `.addressName`, value equality via `.id`, etc. All of these exist on `EnginePeer` with identical semantics. - -Known sites from inventory (accept as same-type): -- **ContactSelectionController.swift**: 67, 76 — `.id`, `.indexName`. -- **ContactListNode.swift**: 121, 177, 209, 216, 251, 255, 491, 505, 519, 520, 782, 787, 827, 833, 1636, 1966 — `.id`/`.addressName`/value comparisons on `.id`. Sites 204 and 251 also appear in category δ because the same binding is used both ways in the same block. -- **ContactsSearchContainerNode.swift**: 151 — `.addressName`. -- **ContactMultiselectionController.swift**: 347, 468 — `.id`. -- **ContactMultiselectionControllerNode.swift**: 491 — `selectedPeers.first` destructure to access `.id`. -- **ContactSelectionController.swift (TelegramUI)**: 504 — context-action passthrough. -- **ComposeController.swift**: 120, 160 — `.id` for chat creation. -- **ChatSendAudioMessageContextPreview.swift**: 88 — `.contact`/name accessors. - -These need no code edits; they are listed only to record coverage. - -### Category ζ — Outflow-to-`Peer`-typed-API (bridge required) - -Any destructured `peer` (now `EnginePeer`) passed to a function that takes raw `Peer` needs `._asPeer()` appended at the call site. - -Known candidate from inventory: -- **ContactsSearchContainerNode.swift:180** — `isPeerEnabled(peer)`. Verify the parameter type at edit time. If it is `(EnginePeer) -> Bool`, no bridge needed; if `(ContactListPeer) -> Bool`, also no bridge (the destructured value is discarded for the overall `peer` value anyway). If `(Peer) -> Bool`, add `._asPeer()`. - -Plan-time step 7 verifies each category-ε site against the API it feeds into; any surprise is resolved by adding `._asPeer()` inline. - -## Out-of-scope — name collisions - -Files listed in the 20-file grep but not touched in this wave: -- **PeerInfoUI/ChannelMembersController.swift**, **PeerInfoUI/ChannelVisibilityController.swift**, **SettingsUI/…/GlobalAutoremoveScreen.swift**, **IncomingMessagePrivacyScreen.swift**, **SelectivePrivacySettingsController.swift**, **SelectivePrivacySettingsPeersController.swift**, **PresentAddMembers.swift**, **ComposeController.swift (TelegramUI)**, **OpenResolvedUrl.swift**, **ChatSendAudioMessageContextPreview.swift** — the inventory found only `ContactListPeerId.peer(…)` destructures or pass-throughs of the entire `ContactListPeer` enum value, not `ContactListPeer.peer` payload access. The payload-type migration does not affect these. - -Plan-time verification: re-grep these files for `case .peer(let peer`, `case let .peer(peer,`, and `.peer(peer:` before declaring "no edits needed". If a missed payload destructure surfaces, promote the file into scope. - -## Execution plan outline (for writing-plans) - -Single atomic commit ordering: - -1. Edit `AccountContext/ContactSelectionController.swift` — change case payload type (L62); change `indexName` property return type to `EnginePeer.IndexName` (L74); rewrite `lhsPeer.isEqual(rhsPeer)` to `lhsPeer == rhsPeer` (L86). -2. Edit `ContactListNode.swift` — drop 12 `._asPeer()` bridges (outflow); drop 4 inflow `EnginePeer(peer)` wraps (2 on L204, 1 on L252, 1 on L844); rewrite cast chain at L182-186 to EnginePeer case patterns; rewrite cast at L1968; drop 2 `EnginePeer.IndexName(...)` wraps on L517. -3. Edit `ContactsController.swift` — drop 1 inflow `EnginePeer(peer)` wrap at L294. -4. Edit `ContactsSearchContainerNode.swift` — drop 3 `._asPeer()` bridges at L494/535/569; drop 4 inflow `EnginePeer(peer)` wraps (2 on L164, 1 on L165, 1 on L181). Do NOT drop `._asPeer()` at L488/528/562 (these feed `canSendMessagesToPeer(_: Peer)` — deferred wave). -5. Edit `TelegramUI/ContactMultiselectionController.swift` — drop 2 outflow bridges at L451/459; drop 4 inflow wraps at L386/403/481/491. Do NOT edit L171/201/748 (these feed `peerTokenTitle(peer: Peer)` — deferred). -6. Edit `TelegramUI/ContactMultiselectionControllerNode.swift` — drop 1 outflow bridge at L317; drop 1 inflow wrap at L492. -7. Edit `TelegramUI/ContactSelectionController.swift` — drop 2 inflow wraps at L517/527. -8. Edit `TelegramUI/ContactSelectionControllerNode.swift` — drop 2 outflow bridges at L160/230. -9. Verify `ComposeController.swift` and `ChatSendAudioMessageContextPreview.swift` need no body edits. If build surfaces a leak, fold the fix into an additional task step. -10. Build: `source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError`. -11. Address undercount misses (expected ≤3 — pre-flight was thorough but file count is large) and commit once build is green. - -## Risk register - -| Risk | Mitigation | -|------|------------| -| Inventory undercount (wave 35 had 14%; trend decreasing) | Pre-flight already uses validated pattern set. `--continueOnError` on the build surfaces all misses in one pass. Expected ≤2 missed sites. | -| Destructure sites that flow a peer into a raw-`Peer`-typed API (category ζ) not caught by inventory | Build will flag the type mismatch; fix inline with `._asPeer()` at the flagged call site. Plan step 8 is the explicit verification gate. | -| `ContactListPeer` Equatable semantic regression | Replacing `lhsPeer.isEqual(rhsPeer)` (Postbox dynamic dispatch) with `lhsPeer == rhsPeer` (EnginePeer synthesized `==`) compares the same underlying concrete types (`.user(TelegramUser)`, `.channel(TelegramChannel)`, etc.) via their own Equatable conformances. Truth table preserved. | -| `ContactListPeer.indexName` return-type change cascades beyond ContactListNode:517/539/590 | Consumers of `ContactListPeer.indexName` enumerated via `grep -rn "\.indexName" submodules/ --include="*.swift"` filtered for ContactListPeer-typed receivers: only ContactListNode has such uses. No other submodule destructures or pattern-matches on this property. Build will flag any miss immediately. | -| `peer.isEqual` used elsewhere in scope files but on non-ContactListPeer bindings | Inventory confirmed ContactListNode:306 uses `!=` on a `ContactListNodeEntry.peer` binding, not `ContactListPeer.peer`. Scope boundary respected. No other `isEqual` call on a ContactListPeer-destructured binding was found. | -| Files flagged "no ContactListPeer.peer payload access" turn out to have one | Plan step 8 re-greps these files; any hit gets promoted into scope without rerunning the wave. | -| Pre-existing WIP on `ChatListFilterPresetController.swift` / `ChatListFilterPresetListController.swift` | Out of wave scope — untouched. No ContactListPeer reference expected in those files. | - -## Validation - -- Full Bazel build (`--configuration=debug_sim_arm64 --continueOnError`). -- No TelegramCore/Postbox/TelegramApi errors (scope boundary check — halt if they surface). -- Grep post-commit: `rg "ContactListPeer\.peer\(peer: .*\._asPeer" submodules/` returns empty. -- Grep post-commit: `rg "case \.peer\(peer: .*\._asPeer" submodules/` returns empty (catch shortcut constructions). -- Grep post-commit: no surviving `EnginePeer\(peer\)` in the 10 touched files where `peer` was destructured from a `ContactListPeer.peer` case (manual spot-check — automated grep too noisy). - -## Lessons to carry forward - -- Wave 35's pre-flight pattern set (literal token + `.peer as?`/`is` + outflow-args + `EnginePeer(.peer)` + `._asPeer()`) applied to this wave; record the post-commit undercount percentage to continue the calibration trend (wave 34: ~33%, wave 35: ~14%). -- This wave is dominated by **bridge removal** — 20 outflow `._asPeer()` drops + 16 inflow `EnginePeer(peer)` drops + 2 `EnginePeer.IndexName(...)` drops + 1 `.isEqual` → `==` fix + 2 Postbox-cast chain rewrites. Zero bridge additions. Updated tallies supersede earlier draft counts in this spec. Confirms the ratchet effect: earlier waves added bridges at Peer/EnginePeer boundaries precisely so future waves like this one can drop them atomically. Record the ratio (bridge drops : bridge additions) as a health metric across Peer-typed-API waves. -- Custom enum `==` operators using `Peer.isEqual(_:)` are a predictable Category-F leak in every Peer-payload migration. Future Peer-typed-API waves should grep the enum's defining module for `\.isEqual\(` specifically. -- **Computed properties on the enum that return Postbox types (e.g., `PeerIndexNameRepresentation`) are a second predictable leak** — discovered mid-spec for `ContactListPeer.indexName`. Future Peer-typed-enum waves should grep the enum's definition file for `public var` / `public func` returning any Postbox-defined type (`PeerIndexNameRepresentation`, `PeerNameIndex`, `MessageId`, etc.) before committing to the inventory — changing the return type to the Engine equivalent frequently cascades into consumer-side wrap drops (here, 2 wraps at ContactListNode:517). diff --git a/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md deleted file mode 100644 index 6172953547..0000000000 --- a/docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Wave 34 Design: `FoundPeer.peer: Peer → EnginePeer` - -**Date:** 2026-04-24 -**Wave:** 34 (Postbox → TelegramEngine refactor) -**Predecessor:** Wave 33 (loadedPeerWithId consumer sweep, commit `16d017853a`) - -## Goal - -Migrate the public field `FoundPeer.peer` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum. Drops 4 of the 5 `._asPeer()` bridges introduced by wave 33 and eliminates one Postbox-protocol leak from a `TelegramEngine.Contacts` / `TelegramEngine.Calls` return type. - -## Non-Goals - -- Migrating other Peer-typed-API surfaces (`SendAsPeer`, `makePeerInfoController`, `makeChatRecentActionsController`, `makeChatQrCodeScreen`, `FoundPeer` is the smallest probe in this class — those are separate future waves). -- Dropping `import Postbox` from `SearchPeers.swift`. The `_internal_*` functions in that file still call `postbox.transaction`, `parseTelegramGroupOrChannel`, `AccumulatedPeers`, `updatePeers`. They remain the Postbox-facing layer per project rule. -- Dropping `import Postbox` from any consumer module. None of the touched files reach zero Postbox use through this change alone. -- Auto-synthesizing `Equatable` for `FoundPeer`. Manual `==` is preserved per user decision. - -## Scope - -One atomic commit. Approximately 46 semantic edits plus type-name continuations across: - -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` (definition + `_internal_searchPeers` body) -- 7 consumer files in `submodules/`: - - `submodules/TelegramCallsUI/Sources/VideoChatScreen.swift` - - `submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift` - - `submodules/ContactListUI/Sources/ContactListNode.swift` - - `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` - - `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift` - - `submodules/TelegramBaseController/Sources/TelegramBaseController.swift` - - `submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift` - -The remaining ~10 files identified by `grep -rln "FoundPeer\b"` (StorageUsageExceptionsScreen field-only refs aside, the file IS in the touched list above) contain only C5 type-name mentions or unrelated `.peer.peer` accesses on other types and require no edit. - -**Verification (performed 2026-04-24)** that nearby `EnginePeer(peer.peer)` patterns in other files are NOT FoundPeer access: those sites bind `peer` to `SelectivePrivacyPeer`, `SendAsPeer`, `InactiveChannel`, `RenderedChannelParticipant`, or `RenderedPeer` — all of which still expose `.peer: Peer`. They remain unchanged by this wave. - -## Changes - -### 1. `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` - -**Struct:** - -```swift -public struct FoundPeer: Equatable { - public let peer: EnginePeer // was: Peer - public let subscribers: Int32? - - public init(peer: EnginePeer, subscribers: Int32?) { // was: peer: Peer - self.peer = peer - self.subscribers = subscribers - } - - public static func ==(lhs: FoundPeer, rhs: FoundPeer) -> Bool { - return lhs.peer == rhs.peer && lhs.subscribers == rhs.subscribers - // was: lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers - } -} -``` - -**`_internal_searchPeers` body changes:** - -- All four `FoundPeer(peer: peer, subscribers: …)` constructions (lines 70, 72, 85, 87) wrap the raw `peer` value with `EnginePeer(peer)`. -- Six scope-filter expressions (2 per non-trivial scope × 3 scopes — `.channels` lines 96–109, `.groups` lines 110–128, `.privateChats` lines 129–143) rewrite to enum pattern matching: - - `as? TelegramChannel, case .broadcast = channel.info` → `if case let .channel(channel) = item.peer, case .broadcast = channel.info` - - `as? TelegramChannel, case .group = channel.info` plus `else if item.peer is TelegramGroup` → `if case let .channel(channel) = item.peer, case .group = channel.info` plus `else if case .legacyGroup = item.peer` - - `if item.peer is TelegramUser` → `if case .user = item.peer` - -Filter behavior is preserved exactly; only the destructuring form changes. - -### 2. Consumer-side edits (by category) - -Inventory was performed on 2026-04-24 via Explore agent against the 10 files identified by `grep -rln "FoundPeer\b" submodules/ Telegram/`. An additional 3 files surfaced (`ShareControllerNode.swift`, `SharePeersContainerNode.swift`, `PeerSelectionControllerNode.swift`, `ContactSelectionControllerNode.swift`, `ChatListNode.swift`) — most are C5 type-name mentions or false positives in field names that don't reference the type. - -**C1 — peer-protocol method reads (~28 sites): no edit required.** -`peer.peer.id`, `peer.peer.displayTitle`, `peer.peer.namespace`, `peer.peer.debugDisplayTitle`, `peer.peer.smallProfileImage` — all available on `EnginePeer` with the same signatures. - -**C5 — type-signature mentions (~60 sites): no edit required.** -`[FoundPeer]`, `Signal<([FoundPeer], [FoundPeer]), NoError>`, `Atomic<([FoundPeer], [FoundPeer])?>`, `case globalPeer(FoundPeer, …)`, etc. The type continues to compile under the new field. - -**C2 — downcast rewrites (30 sites).** - -EnginePeer is an enum, so `peer.peer as? TelegramX` / `peer.peer is TelegramX` patterns must rewrite to `if case .X = peer.peer` (or `if case let .X(x) = peer.peer` when the bound value is reused). Case mapping: - -- `TelegramUser` → `.user` -- `TelegramSecretChat` → `.secretChat` -- `TelegramGroup` → `.legacyGroup` -- `TelegramChannel` → `.channel` - -| File | Line | Current pattern | After (representative) | -|---|---|---|---| -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 628 | `peer.peer is TelegramGroup` | `if case .legacyGroup = peer.peer` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 631 | `as? TelegramChannel, case .group = peer.info` | `if case let .channel(channel) = peer.peer, case .group = channel.info` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 648 | `as? TelegramChannel, case .broadcast = peer.info` | `if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `ContactListUI/ContactListNode.swift` | 1501 | `if let _ = peer.peer as? TelegramChannel` | `if case .channel = peer.peer` | -| `ContactListUI/ContactListNode.swift` | 1563, 1569, 1574 | `if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium)` | `if case let .user(user) = peer.peer, user.flags.contains(.requirePremium)` | -| `ContactListUI/ContactListNode.swift` | 1658, 1665, 1695, 1703, 1733 | `let user = peer.peer as? TelegramUser` (in if-let chains) | `if case let .user(user) = peer.peer, …` | -| `ContactListUI/ContactListNode.swift` | 1673, 1711 | `if peer.peer is TelegramGroup` (with possible `&& `) | `if case .legacyGroup = peer.peer` (with `, `) | -| `ContactListUI/ContactListNode.swift` | 1675, 1713 | `else if let channel = peer.peer as? TelegramChannel` | `else if case let .channel(channel) = peer.peer` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1024 | `!(peer.peer is TelegramUser \|\| peer.peer is TelegramSecretChat)` | rewrite to combined enum-pattern (×2 within the line) | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1029, 1030 | `if let _ = peer.peer as? TelegramGroup` / `else if let peer = peer.peer as? TelegramChannel, case .group = peer.info` | `if case .legacyGroup = peer.peer` / `else if case let .channel(channel) = peer.peer, case .group = channel.info` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1038, 1040 | `if peer.peer is TelegramUser` / `else if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info` | `if case .user = peer.peer` / `else if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1500, 1507 | `if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info` | `if case let .channel(channel) = peer.peer, case .broadcast = channel.info` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 175, 178, 193 | (see prior lines, same pattern set) | (same) | -| `TelegramBaseController/TelegramBaseController.swift` | 243, 246, 258 | `peer.peer is TelegramGroup` / `as? TelegramChannel, case .group = peer.info` / `as? TelegramChannel, case .broadcast = peer.info` | (same enum-pattern rewrites as above) | - -Two name-shadowing notes: - -- **Inner `peer` shadowing.** Several rewrites (e.g., `else if let peer = peer.peer as? TelegramChannel`) currently shadow the loop variable with a new `peer` of type `TelegramChannel`. After rewrite these become `else if case let .channel(channel) = peer.peer` — the binding name moves from `peer` to `channel` to avoid further shadowing of the EnginePeer loop variable. Adjust subsequent body references inside the if-let scope (they currently say `peer.info` referring to `TelegramChannel.info`; they become `channel.info`). Spot-check each rewrite within its block. -- **`channel.info` references.** When a downcast block uses the bound `peer` for `.info` access (e.g., line 178: `peer.info`), update those references to use the new binding name (`channel.info`). Block-internal-only — no cascade. - -Plus 6 filter sites inside `SearchPeers.swift` `_internal_searchPeers` body (already counted under §1). - -**C4 — constructor edits (6 sites):** - -Bridge-drop sites — wave-33 added `._asPeer()` because the value was already `EnginePeer`; with this wave the field accepts EnginePeer directly: - -| File | Line | Current | After | -|---|---|---|---| -| `TelegramCallsUI/VideoChatScreen.swift` | 1833 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `ContactListUI/ContactListNode.swift` | 1485 | `FoundPeer(peer: mainPeer._asPeer(), subscribers: nil)` | `FoundPeer(peer: mainPeer, subscribers: nil)` | -| `ContactListUI/ContactListNode.swift` | 1517 | `FoundPeer(peer: $0._asPeer(), subscribers: nil)` (inside `peers.map { … }`) | `FoundPeer(peer: $0, subscribers: nil)` | -| `TelegramBaseController/TelegramBaseController.swift` | 208 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 156 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 265 | `FoundPeer(peer: peer._asPeer(), subscribers: nil)` | `FoundPeer(peer: peer, subscribers: nil)` | - -Wrap-needed sites — value at the call site is raw `Peer`, must be wrapped: - -| File | Line | Current | After | -|---|---|---|---| -| `ContactListUI/ContactListNode.swift` | 1506 | `mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers))` | already-EnginePeer (since `peer: FoundPeer` after migration) → `mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers))` — **no edit** | -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 288 | `FoundPeer(peer: peer, subscribers: subscriberCount)` | `FoundPeer(peer: EnginePeer(peer), subscribers: subscriberCount)` | - -Note: ContactListNode:1506 is inside a `for peer in mappedPeers` over `[FoundPeer]`, so `peer.peer` is already `EnginePeer` after migration. No edit. Re-classified from C4-wrap-needed to no-op. - -So: 4 bridge-drop edits + 1 actual wrap (StorageUsageExceptionsScreen:288) = 5 C4 edits, not 6. - -**C3 — drop redundant `EnginePeer(peer.peer)` wrap (22 sites).** - -After migration `peer.peer` is already `EnginePeer`, and `EnginePeer.init(_ peer: Peer)` does not accept an EnginePeer argument — so each `EnginePeer(peer.peer)` wrap MUST be dropped to just `peer.peer` or the build fails. - -| File | Line | Wraps | Pattern (representative) | -|---|---|---|---| -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 173 | 1 | `EnginePeer(peer.peer).displayTitle(…)` → `peer.peer.displayTitle(…)` | -| `SettingsUI/StorageUsageExceptionsScreen.swift` | 176 | 1 | `iconPeer: EnginePeer(peer.peer)` → `iconPeer: peer.peer` | -| `TelegramBaseController/TelegramBaseController.swift` | 265 | 2 | `peer: EnginePeer(peer.peer), title: EnginePeer(peer.peer).displayTitle(…)` → `peer: peer.peer, title: peer.peer.displayTitle(…)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 201 | 1 | `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` → `peerAvatarCompleteImage(… peer: peer.peer, …)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 202 | 1 | `text: EnginePeer(peer.peer).displayTitle(…)` → `text: peer.peer.displayTitle(…)` | -| `PeerInfoScreen/PeerInfoScreenCallActions.swift` | 288 | 2 | `.secondLineWithValue(EnginePeer(peer.peer).displayTitle(…))` and `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1075 | 2 | `peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer))` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1076 | 1 | `interaction.peerSelected(EnginePeer(peer.peer), nil, nil, nil, false)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1078 | 1 | `interaction.disabledPeerSelected(EnginePeer(peer.peer), nil, …)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 1081 | 1 | `peerContextAction(EnginePeer(peer.peer), .search(nil), node, gesture, location)` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3088 | 1 | `filteredPeer(EnginePeer(peer.peer), EnginePeer(accountPeer))` (only the FoundPeer wrap drops; the `EnginePeer(accountPeer)` wrap stays — `accountPeer` is a raw Peer) | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3096 | 1 | same pattern as 3088 | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3214 | 1 | same pattern as 3088 | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3216 | 1 | `entries.append(.localPeer(EnginePeer(peer.peer), …))` | -| `ChatListUI/ChatListSearchListPaneNode.swift` | 3241 | 1 | same pattern as 3088 | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 171 | 2 | `.secondLineWithValue(EnginePeer(peer.peer).displayTitle(…))` and `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 658 | 1 | `peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …)` | -| `TelegramCallsUI/VideoChatScreenMoreMenu.swift` | 679 | 1 | `text: EnginePeer(peer.peer).displayTitle(…)` | -| **Total** | | **22** | | - -Note: only the inner `EnginePeer(peer.peer)` is dropped. Adjacent `EnginePeer()` wraps (e.g., `EnginePeer(accountPeer)` at lines 3088/3096/3214/3241) are unrelated to this wave and remain. - -### Total semantic-edit count - -- §1 (TelegramCore): struct (3 lines) + 6 filter rewrites + 4 constructor wraps = ~13 spot edits in one file -- §2 C2: 30 consumer-site downcast rewrites -- §2 C4: 5 consumer-site constructor edits (4 bridge-drops + 1 wrap) -- §2 C3: 22 consumer-site `EnginePeer(peer.peer)` wrap drops - -**Total: ~70 semantic edits** across 1 TelegramCore file + 7 consumer files. Type-name mentions in signal/collection signatures need no edit; the type continues to compile. - -## Verification - -- **Build:** `source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError` -- **Expected outcome:** first-pass-clean build. Errors that surface most likely indicate (a) a missed C2 site, (b) a FoundPeer field-access I missed in the inventory, or (c) a downstream API receiving `peer.peer` that requires raw `Peer` (would need a `._asPeer()` bridge added). -- **Post-build grep validations:** - - `grep -rn "FoundPeer(peer:.*\._asPeer()" submodules/` → expect zero hits in production code (the 4 bridge-drops succeeded). - - `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" ` → expect zero hits in the 7 touched consumer files (FoundPeer-relevant downcasts all rewritten). Other unrelated `something_else.peer.peer as?` patterns may remain on `RenderedPeer` etc. - - `grep -rn "EnginePeer(peer\.peer)" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/"` → expect zero hits in the 7 touched consumer files (other files keep their wraps because their `peer` is non-FoundPeer). - -## Risks and mitigations - -- **Misnamed enum case bindings (C2).** A wrong binding name (e.g. `if case let .channel(c) = peer.peer` then accessing `channel.info`) compiles but is a typo. *Mitigation:* the rewrites are mechanical and each table-row in §2 above shows the exact target form. Each binding is reused inside the same `if case let` clause. -- **Hidden field accesses missed by the inventory.** *Mitigation:* `--continueOnError` build catches everything in one pass. If 5+ unexpected error sites surface, abandon and re-inventory. If only 1–2 surface, fix in place. -- **Downstream APIs requiring raw `Peer`.** Some consumer code may pass `foundPeer.peer` to a function taking the `Peer` protocol. Inventory found 2 such sites already simplified (C3), but unknown sites may exist. *Mitigation:* if surfaced by build errors, bridge with `._asPeer()` at the call site (acceptable transitional pattern — these become next-wave candidates for downstream migration). -- **Equatable behavior change.** `Peer.isEqual(_:)` is the protocol's polymorphic identity test; `EnginePeer.==` is the synthesized-or-manual enum equality. *Mitigation:* `EnginePeer.==` is the canonical equality on the enum and is used throughout the engine codebase. The two should agree on identity-relevant fields (peer id, namespace), and FoundPeer equality is used in `Equatable` set/array dedup contexts where both forms produce the same answer for distinct peers. If tests existed, this would be the place to add one — they don't, so we accept the substitution. - -## Out-of-scope cleanups (for future waves) - -- The downstream `peerAvatarCompleteImage(account:peer:size:)` in `PeerInfoScreenCallActions.swift:202` accepts `EnginePeer` — no change needed there. -- Wave 33's 5th `._asPeer()` bridge (the one not at a `FoundPeer` constructor) remains. It is at a different downstream API — separate wave. -- `SendAsPeer`, `makePeerInfoController`, `makeChatRecentActionsController`, `makeChatQrCodeScreen` migrations — each is its own wave, larger blast radius. diff --git a/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md deleted file mode 100644 index 4ac458691a..0000000000 --- a/docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# Wave 39 — `makePeerInfoController` peer: Peer → EnginePeer migration - -Date: 2026-04-24 - -## Context - -Ring-2 cleanup of the `AccountContext` Peer-typed-API surface. Waves 34 (FoundPeer.peer), 35 (SendAsPeer.peer), 36 (ContactListPeer.peer), 37 (peerTokenTitle), and 38 (canSendMessagesToPeer) migrated adjacent Peer-typed APIs to `EnginePeer`. `makePeerInfoController` is the largest remaining Peer-typed-API surface on `AccountContext` and a natural follow-up. - -Scope: only `makePeerInfoController` this wave. The sibling methods `makeChatQrCodeScreen` (4 consumer sites) and `makeChatRecentActionsController` (3 consumer sites) are deferred to a trivial follow-up wave. - -## Signature change - -`AccountContext` protocol declaration (`submodules/AccountContext/Sources/AccountContext.swift:1371`) and its `SharedAccountContextImpl` implementation (`submodules/TelegramUI/Sources/SharedAccountContext.swift:1937`): - -```swift -// before -func makePeerInfoController( - context: AccountContext, - updatedPresentationData: (initial: PresentationData, signal: Signal)?, - peer: Peer, - mode: PeerInfoControllerMode, - avatarInitiallyExpanded: Bool, - fromChat: Bool, - requestsContext: PeerInvitationImportersContext? -) -> ViewController? - -// after -func makePeerInfoController( - context: AccountContext, - updatedPresentationData: (initial: PresentationData, signal: Signal)?, - peer: EnginePeer, - mode: PeerInfoControllerMode, - avatarInitiallyExpanded: Bool, - fromChat: Bool, - requestsContext: PeerInvitationImportersContext? -) -> ViewController? -``` - -Implementation body adds `let peer = peer._asPeer()` shadow at body-top. `peerInfoControllerImpl` (private, same file) and all downstream Peer-typed helpers keep raw `Peer` — out of scope for this wave. - -```swift -public func makePeerInfoController(... peer: EnginePeer ...) -> ViewController? { - let peer = peer._asPeer() - let controller = peerInfoControllerImpl(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: avatarInitiallyExpanded, isOpenedFromChat: fromChat) - controller?.navigationPresentation = .modalInLargeLayout - return controller -} -``` - -## Consumer-side changes - -**73 total consumer call sites** (75 raw occurrences minus 1 protocol declaration and 1 implementation). Classification (confirmed via full-repo grep): - -- **58 Shape-A** — inline `peer: x._asPeer()` drops to `peer: x`. Mechanical edits. -- **3 Shape-A-variant** — `SettingsSearchableItems.swift` lines 1023, 1049, 1083. The upstream `guard let peer = peer?._asPeer() else` changes to `guard let peer = peer else`, making the local `peer` stay `EnginePeer`. The call-site line does not change. -- **12 Shape-C** — raw Peer local, add `EnginePeer(...)` wrap at call site. - -### Shape-C site list - -| File | Line | Current peer argument | New | -|---|---|---|---| -| `submodules/SettingsUI/Sources/Privacy and Security/BlockedPeersController.swift` | 270 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` | 707 | `peer: participant.peer` | `peer: EnginePeer(participant.peer)` | -| `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` | 381 | `peer: participant.peer` | `peer: EnginePeer(participant.peer)` | -| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` | 1011 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` | 4306 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 441 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 461 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 471 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift` | 492 | `peer: channel` | `peer: EnginePeer(channel)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift` | 218 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift` | 359 | `peer: peer` | `peer: EnginePeer(peer)` | -| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | 4362 | `peer: peer` | `peer: EnginePeer(peer)` | - -Each Shape-C wrap is a future-wave drop candidate once the raw-Peer source (stored field, `participant.peer`, `renderedPeer.chatMainPeer`, etc.) migrates upstream. - -### Shape-A-variant detail - -`SettingsSearchableItems.swift` three sites share the same structure: - -```swift -// before -let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) -|> deliverOnMainQueue).start(next: { peer in // peer: EnginePeer? - guard let peer = peer?._asPeer() else { // peer: Peer (shadowed) - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - ... - ) - ... -}) - -// after -let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) -|> deliverOnMainQueue).start(next: { peer in // peer: EnginePeer? - guard let peer = peer else { // peer: EnginePeer (shadowed) - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - ... - ) - ... -}) -``` - -## Files touched (≈50) - -Inventoried from the grep output. Not exhaustive here; per-site enumeration lives in the implementation plan. - -Signature files: `AccountContext/Sources/AccountContext.swift`, `TelegramUI/Sources/SharedAccountContext.swift`. - -Shape-A consumer files (sample, not exhaustive): `SelectivePrivacySettingsPeersController.swift`, `InstantPageControllerNode.swift`, `CallListController.swift`, `ContactsController.swift`, `ContactContextMenus.swift`, `SecureIdAuthController.swift`, `ChannelAdminController.swift`, `ChannelMembersController.swift`, `ChannelBannedMemberController.swift`, `ChannelPermissionsController.swift`, `MessageStatsController.swift`, `GroupStatsController.swift`, `InviteRequestsController.swift`, `BrowserInstantPageContent.swift`, `WebAppController.swift`, `PeersNearbyController.swift`, `ChatSendStarsScreen.swift`, `ChatRecentActionsControllerNode.swift`, `MiniAppListScreen.swift`, `JoinSubjectScreen.swift`, `NewContactScreen.swift`, `StarsTransactionScreen.swift`, `StoryItemSetContainerViewSendMessage.swift`, `StoryItemSetContainerComponent.swift`, `GiftViewScreen.swift`, `GiftOptionsScreen.swift`, `StorageUsageScreen.swift`, `TextProcessingScreen.swift`, `PeerInfoScreen.swift`, `PeerInfoScreenOpenURL.swift`, `JoinAffiliateProgramScreen.swift`, `ChatControllerScrollToPointInHistory.swift`, `OpenUrl.swift`, `OpenResolvedUrl.swift`, `TextLinkHandling.swift`, `ChatController.swift`, `OpenAddContact.swift`, `ChatManagingBotTitlePanelNode.swift`, `NavigateToChatController.swift`, `SharedAccountContext.swift` (3 self-call sites), `OverlayAudioPlayerControllerNode.swift`, `PollResultsController.swift`, `ChatControllerOpenWebApp.swift`, `ChatControllerNavigationButtonAction.swift`, `ChatListController.swift`, `ChatListSearchListPaneNode.swift`. - -Shape-A-variant file: `SettingsSearchableItems.swift`. - -Shape-C-only files (other than those with mixed shapes above): `BlockedPeersController.swift`, `ChannelBlacklistController.swift`, `ChatControllerOpenPeer.swift`, `ChatControllerLoadDisplayNode.swift`. - -## Build/verification plan - -1. Apply all edits atomically. Mechanical Edit-tool string replaces for the 58 Shape-A drops; focused Edits for the 3 Shape-A-variants (guard line) and 12 Shape-C wraps. -2. Full project build: `source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError`. -3. Fix any iteration-surfaced errors. Budget 2–4 iterations. -4. Clean build → atomic commit with wave-39 message. -5. Update `project_postbox_refactor_next_wave.md` memory, `docs/superpowers/postbox-refactor-log.md`, and `CLAUDE.md` wave tally. -6. No test runs (project has no unit tests). - -## Risks / watch-out - -- **Destructure/binding cascades.** Locals named `peer` declared as `Peer` somewhere in a call chain and fed to `makePeerInfoController`. The body-shadow pattern contains divergence at the public API boundary, but transient Swift inference errors may surface at intermediate points. -- **`chatMainPeer` / `renderedPeer.peer` property types.** Shape-C sites at `ChatControllerNavigationButtonAction.swift:441/461/471/492` and `ChatControllerLoadDisplayNode.swift:4362` assume these properties return raw `Peer`. If they already return `EnginePeer` in the current repo (unlikely but possible after earlier waves), the wrap should be `peer: peer` with no wrap. Verify in plan phase. -- **Outflow sites in Shape-C files.** Some Shape-C files may have additional `peer: Peer` flows elsewhere that are unrelated to this wave. Do not chase — only touch the listed sites. - -## Abandonment criteria - -- Iteration count exceeds 5. -- A cascade requires editing `peerInfoControllerImpl` (violates body-shadow boundary). -- Any non-consumer file (e.g., anything in `TelegramCore`, `Postbox`, `TelegramApi`) surfaces an error. - -## Net effect - -- Public API: `AccountContext.makePeerInfoController` takes `EnginePeer` instead of raw `Peer`. -- Bridges: -58 inline `_asPeer()` + -3 upstream-guard `_asPeer()` + 12 new `EnginePeer(...)` wraps = **net -49 bridges**. -- Ratchet: 12 Shape-C wraps become future-wave drop candidates (e.g., `RenderedPeer → EngineRenderedPeer` migration, participant-object migrations). - -## Out of scope - -- `makeChatQrCodeScreen` (4 sites), `makeChatRecentActionsController` (3 sites) — deferred to a trivial follow-up wave. -- `peerInfoControllerImpl` and downstream Peer-typed helpers. -- Shape-C source migrations (participant objects, `renderedPeer.chatMainPeer`, etc.). diff --git a/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md deleted file mode 100644 index 06ff615cc8..0000000000 --- a/docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Postbox → TelegramEngine wave 37: peerTokenTitle peer parameter Peer → EnginePeer" -date: 2026-04-24 -status: draft ---- - -# Wave 37 design — `peerTokenTitle` peer parameter Peer → EnginePeer - -## Context - -Wave 36 (commit `069a060de1`, squashed into `8408e0ae19`) migrated `ContactListPeer.peer` from `Peer` to `EnginePeer` and added two new `peer._asPeer()` bridges at `ContactMultiselectionController.swift:386` and `:403`, feeding the private free function `peerTokenTitle(accountPeerId: PeerId, peer: Peer, ...)` at `:21`. - -Wave 37 migrates `peerTokenTitle`'s `peer` parameter so those two new bridges — plus three older bridges at `:171`, `:201`, and `:748` — can all drop to zero in one atomic commit. This is a ring-2 cleanup: it consumes bridges that prior waves installed. - -## Scope - -All changes are confined to `submodules/TelegramUI/Sources/ContactMultiselectionController.swift`. - -### Changes - -| Location | Before | After | -|---|---|---| -| L21 | `peer: Peer` | `peer: EnginePeer` | -| L27 | `EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)` | `peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)` | -| L171 | `peer: peer._asPeer()` | `peer: peer` | -| L201 | `peer: peer._asPeer()` | `peer: peer` | -| L386 | `peer: peer._asPeer()` | `peer: peer` | -| L403 | `peer: peer._asPeer()` | `peer: peer` | -| L748 | `peer: peer._asPeer()` | `peer: peer` | - -All 5 call-site bindings `peer` are already `EnginePeer` at the call site — verified by the existing `._asPeer()` bridge. - -The function body at L22–L28 stays semantically identical: `peer.id`, `peer.id.isReplies`, and `EnginePeer.displayTitle(strings:displayOrder:)` are all available on `EnginePeer`. - -### Intentionally out of scope - -- **`accountPeerId: PeerId`** — `PeerId` is already typealiased to `EnginePeer.Id`; not a Postbox-type leak. -- **`import Postbox` at L5** — other parts of the file still use Postbox-typed APIs (e.g., `.peer(peer: Peer, ...)` at L459 feeding the `SelectedPeer` enum). File-level Postbox-free is a later wave. -- **L459's `peer._asPeer()`** — feeds a different, not-yet-migrated Peer-typed API (`SelectedPeer.peer(peer: Peer, ...)`), outside this wave. -- **Other callers** — `peerTokenTitle` is `private` to this file; a full-codebase grep confirmed zero external call sites. - -## Verification - -1. **Pre-build grep** — confirm zero remaining `peerTokenTitle(.*_asPeer())` matches in the file and the broader codebase. -2. **Single full project build** via `Make.py` with `--continueOnError`. Expected first-pass-clean. -3. **Post-build grep** — same `peerTokenTitle(.*_asPeer())` pattern should remain empty. - -## Risk - -**Very low.** Private free function, single file, fully self-contained, all call sites mechanical bridge drops. No public-API change, no BUILD-file touch, no other modules affected. - -Expected outcome: first-pass-clean build. Good reset after wave 36's 6-iteration convergence. - -## Commit message - -``` -Postbox → TelegramEngine wave 37 - -peerTokenTitle: peer parameter Peer → EnginePeer. - -Drops 5 _asPeer() bridges in ContactMultiselectionController.swift -(L171, L201, L386, L403, L748) — bridges installed by prior waves. - -Private free function, single-file change. -``` - -## References - -- CLAUDE.md — "Postbox → TelegramEngine refactor (in progress)" -- `docs/superpowers/postbox-refactor-log.md` — wave history -- Memory `project_postbox_refactor_next_wave.md` — wave-37 candidate list diff --git a/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md b/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md deleted file mode 100644 index cf7472b690..0000000000 --- a/docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md +++ /dev/null @@ -1,187 +0,0 @@ -# Wave 44 — `RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]` - -**Date:** 2026-04-24 -**Status:** Approved, pending plan -**Predecessor:** Wave 41 (commit `32573c9808`) migrated `RenderedChannelParticipant.peer` from `Peer` to `EnginePeer` and installed ADD-WRAP markers at consumer-side read sites that this wave drops. -**Goal:** Close out the wave-41 ratchet by migrating the sibling `peers: [PeerId: Peer]` field to `[EnginePeer.Id: EnginePeer]`. After this wave, `RenderedChannelParticipant` has no raw `Peer` types in its public surface. - -## Context - -`RenderedChannelParticipant` is declared in `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`: - -```swift -public struct RenderedChannelParticipant: Equatable { - public let participant: ChannelParticipant - public let peer: EnginePeer // migrated in wave 41 - public let peers: [PeerId: Peer] // target of this wave - public let presences: [PeerId: PeerPresence] // out of scope (PeerPresence is Postbox protocol) - - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { ... } -} -``` - -`peers` is a supplementary dict of "referenced peers" (e.g. the admin who promoted this member, the admin who banned them). Consumers use it to render relationships — never with `as?`/`is` casts, only `.id` and `.displayTitle(...)` on extracted values. - -## Migration target - -- `peers: [PeerId: Peer]` → `peers: [EnginePeer.Id: EnginePeer]` -- init default: `[:]` on both sides (type changes transparently) -- `presences` field stays unchanged. - -## Scope - -### Declaration (1 file, 2 edits) - -**`submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`** - -- Line 11: `public let peers: [PeerId: Peer]` → `public let peers: [EnginePeer.Id: EnginePeer]` -- Line 14: `peers: [PeerId: Peer] = [:]` → `peers: [EnginePeer.Id: EnginePeer] = [:]` - -### TelegramCore producer sites (8 files, 8 construction sites, ~16 edits) - -All 8 producers follow the identical pattern of building a local `peers: [PeerId: Peer] = [:]` dict inside a `postbox.transaction` and passing it to an `RCP(peers: peers, ...)` constructor. Per-site edits: change local dict type, wrap each insertion value with `EnginePeer(...)`. - -| File | `var peers:` decl | `peers[X.id] = X` insertions | RCP construction | -|---|---|---|---| -| `TelegramEngine/Messages/RequestStartBot.swift` | line 61 | line 64 | line 65 | -| `TelegramEngine/Peers/ChannelOwnershipTransfer.swift` | line 170 | lines 172, 176 | line 180 (2 RCP constructions share `peers`) | -| `TelegramEngine/Peers/JoinChannel.swift` | line 59 | lines 64, 77 | line 82 | -| `TelegramEngine/Peers/AddPeerMember.swift` | line 242 | lines 244, 251 | line 255 | -| `TelegramEngine/Peers/PeerAdmins.swift` | line 251 | lines 253, 259 | line 262 | -| `TelegramEngine/Peers/ChannelBlacklist.swift` | line 128 | lines 130, 136 | line 140 | -| `TelegramEngine/Peers/Ranks.swift` | line 60 | lines 62, 68 | line 95 | -| `TelegramEngine/Peers/ChannelMembers.swift` | line 102 | line 105 | line 115 | - -**Per-site rewrite:** -```swift -// before -var peers: [PeerId: Peer] = [:] -peers[peer.id] = peer - -// after -var peers: [EnginePeer.Id: EnginePeer] = [:] -peers[peer.id] = EnginePeer(peer) -``` - -### Consumer-side DROPs: `.mapValues({ $0._asPeer() })` transforms (5 sites) - -These consumer-side constructors start from a `[PeerId: EnginePeer]` source dict and currently unwrap to `[PeerId: Peer]` to feed into the RCP constructor. After migration, the unwrap transform is a no-op and can be dropped. - -| File | Line | Before → after | -|---|---|---| -| `PeerInfoUI/Sources/ChannelAdminsController.swift` | 926 | `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers` | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 994 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 998 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` | 409 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` | 413 | same | - -**Verification required at plan time:** for each of these 5 sites, grep back up in the enclosing function to confirm the local `peers` variable is declared `[PeerId: EnginePeer]` (the source of the mapValues transform). If any of the sources turn out to be `[PeerId: Peer]` rather than `[PeerId: EnginePeer]`, that site's transform is NOT a no-op and instead becomes a wrap (`.mapValues(EnginePeer.init)`) — still a net-zero or gain depending on where the source originates. - -### Consumer-side DROPs: `EnginePeer(peer).displayTitle(...)` wraps (6 sites) - -These are the wave-41 ADD-WRAP markers. Pattern: extract `peer` from `participant.peers[X]`, wrap with `EnginePeer(peer)` to call `.displayTitle(...)`. After migration, `peer` is already `EnginePeer` — drop the wrap. - -| File | Line | Pattern | -|---|---|---| -| `PeerInfoUI/Sources/ChannelAdminsController.swift` | 297 | `EnginePeer(peer).displayTitle(strings: strings, ...)` → `peer.displayTitle(strings: strings, ...)` | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 839 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 870 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 1091 | same | -| `PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` | 1122 | same | -| `PeerInfoUI/Sources/ChannelBlacklistController.swift` | 165 | same | - -The adjacent `peer.id == participant.peer.id` comparisons are unchanged: both sides are `EnginePeer.Id` (already a typealias of `PeerId`). - -### Consumer-side ADD-UNWRAP (1 site) - -**`submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`**, lines 672–674: - -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer // `peers` is SimpleDictionary -} -``` - -After migration `peer` is `EnginePeer`; the outer `peers` SimpleDictionary is still `[PeerId: Peer]`. Rewrite: - -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer._asPeer() -} -``` - -### Constructor sites with no `peers:` arg — no change (12 sites) - -Default value's *type* changes (`[PeerId: Peer] = [:]` → `[EnginePeer.Id: EnginePeer] = [:]`) but the literal `[:]` works for either. These sites compile unchanged: - -- TelegramCore: `ChannelAdminEventLogs.swift:271, 279` (x2), `:287` (x2), `:483` (x2) — 7 constructions -- `PeerInfoUI/.../ChannelAdminsController.swift:921` -- `PeerInfoUI/.../ChannelMembersSearchContainerNode.swift:987` -- `PeerInfoUI/.../ChannelMembersSearchControllerNode.swift:404` -- `TelegramUI/.../ChatRecentActionsController/.../ChatRecentActionsFilterController.swift:445` -- `TelegramUI/.../ChatControllerAdminBanUsers.swift:224, :370, :755` (3 constructions) -- `TelegramUI/.../StoryContainerScreen/.../StoryContentLiveChatComponent.swift:361` - -## Net impact - -**Consumer-surface bridges:** −6 wraps + −5 unwrap transforms + +1 unwrap = **−10 bridges**. - -**TelegramCore-internal bridges:** +~12 wraps (`EnginePeer(peer)` at producer insertion points, inside `import Postbox` modules). These do not regress Postbox-hygiene since every producer file already imports Postbox. - -**Structural:** `RenderedChannelParticipant` public surface contains no raw `Peer` types after this wave (only `ChannelParticipant`, `EnginePeer`, `[EnginePeer.Id: EnginePeer]`, `[PeerId: PeerPresence]`). `presences` still leaks `PeerPresence` — separate future migration. - -## Iteration budget - -**2–3 iterations** (wave-41 foundational-type lesson: field migrations on passed-around structs budget 2–4 iterations, not first-pass-clean). - -Verified absence of hidden grep surface: -- No `as?` / `is TelegramX` casts on `participant.peers[X]` extractions (grepped). -- No Peer-only properties accessed on extractions (uses `.id` and `.displayTitle(...)` only — both EnginePeer-forwarded). -- All 8 TelegramCore producers build locally (verified) — no chain-migration. - -## Risks - -1. **Producer local-dict migration under `continueOnError`.** If a producer builds the dict with more than two insertions and misses one, the build flags mismatched dict-value types. Low blast radius (per-file local). -2. **Hidden consumer site.** If a grep miss surfaces a `participant.peers` site not enumerated here, the wrap/unwrap balance changes. Mitigation: plan document must re-run the narrow grep (`participant\.peers|rcp\.peers|renderedParticipant\.peers`) at plan-write time and iteration-0 time. -3. **mapValues source-dict check.** If any of the 5 consumer-side `.mapValues({ $0._asPeer() })` sites has a source `[PeerId: Peer]` (not `[PeerId: EnginePeer]`), the migration at that site inverts (becomes a wrap instead of a drop). Plan-time per-site verification required. -4. **SimpleDictionary import.** The one ADD-UNWRAP site in `ChatRecentActionsHistoryTransition.swift` already uses `SimpleDictionary` — no new Postbox exposure. - -## Out of scope - -- `RenderedChannelParticipant.presences: [PeerId: PeerPresence]` — `PeerPresence` is a Postbox protocol; separate migration with different shape. -- `RenderedPeer → EngineRenderedPeer` foundational-type migration (listed in wave-44 memo as candidate 6; save for a dedicated session). -- `PeerInfoHeader*` bundle (wave-44 memo candidate 1) — considered but not selected for wave 44; candidate for wave 45. - -## Success criteria - -1. `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` has `peers: [EnginePeer.Id: EnginePeer]` declaration. -2. All 8 TelegramCore producers compile with wrapped inserts. -3. All 5 consumer `.mapValues({ $0._asPeer() })` transforms are removed. -4. All 6 consumer `EnginePeer(peer).displayTitle(...)` wraps on extracted dict values are removed (`peer.displayTitle(...)`). -5. `ChatRecentActionsHistoryTransition.swift:673` uses `peer._asPeer()` for the SimpleDictionary insertion value. -6. Full `Telegram/Telegram` build (`configuration=debug_sim_arm64`) is clean — **one** atomic commit. -7. Grep post-migration: `participant\.peers\[` returns only engine-typed call sites; no residual `EnginePeer(peer)` on `.peers[...]` extractions. - -## Commit message template - -``` -Postbox -> TelegramEngine wave 44 - -Migrate RenderedChannelParticipant.peers from [PeerId: Peer] to -[EnginePeer.Id: EnginePeer]. Closes the wave-41 ratchet — the public -struct no longer leaks raw Peer types in any field (presences stays -Postbox-typed; separate migration). - -Consumer-surface: -10 bridges (6 EnginePeer(peer) wraps dropped at -read sites, 5 .mapValues({ $0._asPeer() }) transforms dropped at -constructor sites, 1 ._asPeer() added at -ChatRecentActionsHistoryTransition.swift:673 where the value is -inserted into a raw-Peer SimpleDictionary). - -TelegramCore producers: 8 files, each builds a local -[EnginePeer.Id: EnginePeer] dict from transaction.getPeer() wrapping -at the insertion point. - -No unit tests in this project; full Telegram/Telegram build verified -under configuration=debug_sim_arm64. -``` diff --git a/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md deleted file mode 100644 index d981ca3f83..0000000000 --- a/docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md +++ /dev/null @@ -1,175 +0,0 @@ -# Wave 41 — `RenderedChannelParticipant.peer: Peer → EnginePeer` migration — Design - -**Date:** 2026-04-24 -**Wave:** 41 -**Status:** spec - -## Goal - -Migrate the `peer` field of `TelegramCore.RenderedChannelParticipant` from the Postbox protocol `Peer` to the TelegramCore enum `EnginePeer`. All construction sites and consumer accesses are updated in one atomic commit. - -## Motivation - -- Drops 2 Shape-C `EnginePeer(participant.peer)` wraps installed by wave 39 (`ChannelMembersController.swift:707`, `ChannelBlacklistController.swift:381`). -- Drops ~37 additional `EnginePeer(...)` / `._asPeer()` bridges across the consumer surface (total ~39 bridge drops after counting `EnginePeer(peer.peer).compactDisplayTitle` sites in `AdminUserActionsSheet.swift`). -- Aligns `RenderedChannelParticipant.peer` with the pattern established for `FoundPeer.peer` (wave 34), `SendAsPeer.peer` (wave 35), `ContactListPeer.peer` (wave 36), and all `AccountContext.makeX(peer: ...)` facades (waves 37–40). -- Ratchet candidate for future waves: once `.peer` is `EnginePeer`, the `peers: [PeerId: Peer]` dict field becomes the only Postbox-typed field on the struct — a follow-up wave can migrate `peers: [EnginePeer.Id: EnginePeer]` in isolation. - -## Scope - -### In scope - -**TelegramCore:** -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` — change struct field + init param + Equatable impl -- 9 TelegramCore files containing 16 construction sites where `RenderedChannelParticipant(... peer: peer, ...)` is called with a raw `Peer` from `transaction.getPeer()` — wrap with `EnginePeer(peer)`: - - `Messages/RequestStartBot.swift:65` - - `Peers/AddPeerMember.swift:255` - - `Peers/ChannelAdminEventLogs.swift:271, 279, 287, 483` (7 constructor calls total) - - `Peers/ChannelBlacklist.swift:140` - - `Peers/ChannelMembers.swift:115` - - `Peers/ChannelOwnershipTransfer.swift:180` (2 constructor calls) - - `Peers/JoinChannel.swift:82` - - `Peers/PeerAdmins.swift:262` - - `Peers/Ranks.swift:95` - -**Consumer (17 files):** all sites accessing `participant.peer` or constructing `RenderedChannelParticipant`: -- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` -- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift` -- `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` -- `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` -- `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift` -- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift` -- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift` -- `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift` -- `submodules/TemporaryCachedPeerDataManager/Sources/ChannelMemberCategoryListContext.swift` -- `submodules/TemporaryCachedPeerDataManager/Sources/PeerChannelMemberCategoriesContextsManager.swift` - -### Out of scope (deferred) - -- `RenderedChannelParticipant.peers: [PeerId: Peer]` — still `[PeerId: Peer]` dict. Not migrated this wave. -- `RenderedChannelParticipant.presences: [PeerId: PeerPresence]` — still `[PeerId: PeerPresence]` dict. Not migrated this wave. -- `PeerInfoScreenData.peer → EnginePeer` — future wave 42 candidate (drops 2 wave-40 wraps). -- `RenderedPeer → EngineRenderedPeer` — future major wave; saved for a dedicated session. -- `PeerInfoMember.peer: Peer` enum accessor in `PeerInfoMembers.swift:30-39` — retained as `Peer` for this wave (contained by a single `._asPeer()` inside the `.channelMember` branch). Migration of this accessor is a separate follow-up. - -## Design - -### Struct change - -```swift -// submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift - -public struct RenderedChannelParticipant: Equatable { - public let participant: ChannelParticipant - public let peer: EnginePeer // ← was: Peer - public let peers: [PeerId: Peer] // unchanged - public let presences: [PeerId: PeerPresence] // unchanged - - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { - self.participant = participant - self.peer = peer - self.peers = peers - self.presences = presences - } - - public static func ==(lhs: RenderedChannelParticipant, rhs: RenderedChannelParticipant) -> Bool { - return lhs.participant == rhs.participant && lhs.peer == rhs.peer // ← was: lhs.peer.isEqual(rhs.peer) - } -} -``` - -`EnginePeer` is `Equatable` by enum synthesis (verified — each associated value type is Equatable: `TelegramUser`, `TelegramGroup`, `TelegramChannel`, `TelegramSecretChat`). `==` becomes cleaner. - -### Consumer-site shapes - -Per the pre-flight classification, sites fall into these shapes: - -- **ZERO** (transparent) — `.id`, `.isDeleted`, `.indexName`, `.addressName`, `.compactDisplayTitle`, `.displayTitle(strings:displayOrder:)`, `.displayLetters`, `.debugDisplayTitle`, etc. All exposed on `EnginePeer`. ~160 sites. **No edit.** - -- **DROP** — `EnginePeer(participant.peer)` → `participant.peer`. ~32 consumer sites + 2 `.peer._asPeer()` downgrades that also drop. The biggest class of edits. Key sites: - - `ChannelAdminsController.swift:326, 921, 926` (921, 926 drop `._asPeer()` from constructor) - - `ChannelBlacklistController.swift:170, 381` - - `ChannelMembersController.swift:334, 707` - - `ChannelMembersSearchContainerNode.swift:212 (×2), 223` - - `ChannelMembersSearchControllerNode.swift:148` - - `ChannelPermissionsController.swift:480, 483` - - `SearchPeerMembers.swift:30, 36, 61, 76` - - `ChatRecentActionsController.swift:359` - - `ChatRecentActionsFilterController.swift:217` - - `ChatRecentActionsHistoryTransition.swift:719, 730, 740, 828, 842, 870, 943, 955, 973, 990, 1026` (one `EnginePeer(new.peer)` drop per site) - - `ShareWithPeersScreenState.swift:558, 576` - - `AdminUserActionsSheet.swift:284, 404, 416, 417, 522, 523` (EnginePeer(peer.peer) wraps) - - `StoryContentLiveChatComponent.swift:370` (drops `._asPeer()`) - - `ChatControllerAdminBanUsers.swift:372, 757` (drops `._asPeer()`) - -- **CAST** — `if let user = participant.peer as? TelegramUser, user.botInfo != nil` → `if case let .user(user) = participant.peer, user.botInfo != nil`. 9 sites across 4 files: - - `ChannelMembersController.swift:305` - - `ChannelMembersSearchContainerNode.swift:752, 884, 1052, 1136` - - `ChannelMembersSearchControllerNode.swift:516, 558` - - `ShareWithPeersScreenState.swift:566` - - All 9 follow the identical 2-clause pattern (`as? TelegramUser`, `user.botInfo != nil`). Pattern-match rewrite is mechanically safe. - -- **ADD-ASPEER** — site needs raw `Peer`. 3 sites: - - `ChatRecentActionsHistoryTransition.swift:675` — `peers[participant.peer.id] = participant.peer` → `peers[participant.peer.id] = participant.peer._asPeer()` (assigning into `SimpleDictionary`). - - `ChatRecentActionsHistoryTransition.swift:2275` — same pattern. - - `PeerInfoMembers.swift:33` — `return participant.peer` → `return participant.peer._asPeer()` (outer enum accessor returns `Peer`; deliberately contained — migration of `PeerInfoMember.peer` deferred). - -- **ADD-WRAP** — consumer construction site where the local is raw `Peer` but the field is now `EnginePeer`. 7 sites across 3 files: - - `ChannelMembersSearchContainerNode.swift:987, 994, 998` — `peer: peer` where `peer = peerView.peers[participant.peerId]` is raw `Peer`. → `peer: EnginePeer(peer)`. - - `ChannelMembersSearchControllerNode.swift:404, 409, 413` — same pattern. - - `ChatRecentActionsFilterController.swift:445` — `peer: user` where `user: TelegramUser` (from `case let .user(user) = peer`). → `peer: .user(user)` or `peer: EnginePeer(user)`. Use `peer: .user(user)` (direct enum case) for clarity. - - `ChatControllerAdminBanUsers.swift:226` — `peer: peer` where `peer = author: Peer`. → `peer: EnginePeer(peer)`. - -### TelegramCore-internal constructor sites - -All 16 sites receive a raw `Peer` (from `transaction.getPeer()` / `peers[id]`) and pass it as `peer:`. All become `peer: EnginePeer(peer)`: - -```swift -// Before: -RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: presences) -// After: -RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: presences) -``` - -No shape-selection judgment required — all 16 sites follow this exact template. The `peers` and `presences` dictionaries are unchanged. - -## Risks - -- **R1: CAST semantic preservation.** The 9 `as? TelegramUser` sites all gate on `user.botInfo != nil`. Pattern-match rewrite is `if case let .user(user) = participant.peer, user.botInfo != nil`. Verified: `EnginePeer.user(TelegramUser)` gives access to the same `TelegramUser` instance; `.botInfo` is a `TelegramUser` property. Semantically equivalent. - -- **R2: `==` implementation change.** The struct's `==` goes from `lhs.peer.isEqual(rhs.peer)` (protocol dispatch) to `lhs.peer == rhs.peer` (synthesized). `EnginePeer.==` uses Swift-synthesized enum equality: each case compares associated values. Each associated-value type (`TelegramUser`, `TelegramGroup`, `TelegramChannel`, `TelegramSecretChat`) is `Equatable` via its own `==` implementation. Semantically equivalent to the protocol `isEqual`. - -- **R3: PeerInfoMembers.swift:33 cascade.** `PeerInfoMember.peer: Peer` enum accessor at line 30-39 returns `participant.peer` on the `.channelMember` branch. Fix is a single `._asPeer()`. The outer enum's API stays unchanged — no cascade beyond this file. Future wave can migrate `PeerInfoMember.peer` to `EnginePeer`. - -- **R4: Consumer-side constructor sites in ChannelMembersSearch*Node.** 3 sites each in the `Container` and `Controller` node files construct `RenderedChannelParticipant` for the legacy-group search path. The `peer` local is raw `Peer` from `peerView.peers`. Mechanical wrap with `EnginePeer(peer)` at the `peer:` argument. - -- **R5: `participant.peers` dict staying `[PeerId: Peer]`.** Current code uses `peers.mapValues({ $0._asPeer() })` at construction sites where the local dict is `[EnginePeer.Id: EnginePeer]`. This pattern is unchanged by the wave — the `peers` field is not being migrated. - -- **R6: Hidden consumer sites.** Pre-flight searched: `RenderedChannelParticipant(` constructors across `submodules/`, `participant.peer` access (subagent classification), all files that import TelegramCore/Postbox and reference `RenderedChannelParticipant`. 17 consumer files + 10 TelegramCore files confirmed. Risk of overlooked third-party or sparse consumer: low. - -- **R7: Pre-existing WIP contamination.** `git status` shows unrelated WIP: `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift`, `build-system/bazel-rules/sourcekit-bazel-bsp` submodule marker, several untracked dirs. Wave-39 lesson: enumerate files explicitly in `git add`; run `git status --short` after staging. - -## Verification - -- Single full Bazel build with `--continueOnError` after all edits (extends wave-39 / wave-40 pattern). -- Expected outcome: **first-pass-clean build** based on wave-39 precedent — 52 files / 73 sites / non-propagating signature migration → first-pass-clean. This wave is comparable scale (27 files / ~200+ sites including ZEROs) with even cleaner mechanics: ZERO sites are literally no edit; DROP/CAST/ADD-WRAP/ADD-ASPEER patterns are all mechanical; no inference-dependent return types. -- Budget: 3–5 iterations if classification is wrong; first-pass-clean if classification is exact. - -## Net ratchet economics - -- Bridges dropped: ~37–39 (32 consumer DROPs + 2 `._asPeer()` drops in ChannelAdminsController + ~6 `EnginePeer(peer.peer).X` drops in AdminUserActionsSheet, possibly double-counted; final net post-commit grep will settle the number). -- Bridges added: ~23 (16 TelegramCore `EnginePeer(peer)` wraps at constructor call sites + 4 ADD-WRAP consumer constructors + 3 ADD-ASPEER). -- **Net:** ~−14 to −16 bridges. Positive economics even counting TelegramCore-internal adds. -- Ratchet marker: the 4 consumer ADD-WRAP constructor sites (`ChannelMembersSearch*Node` + `ChatControllerAdminBanUsers:226`) are candidates for drop in a future wave that migrates the `peerView.peers[id]` / `authors: [Peer]` upstream flows to EnginePeer. - -## Out-of-scope inventory (for the next wave) - -If a follow-up wave migrates **`RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]`**, the ADD-WRAP sites in this wave (all `peers: peers.mapValues({ $0._asPeer() })`) simplify to `peers: peers`. That's a high-ratchet candidate wave that becomes mechanical once this wave lands. diff --git a/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md b/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md deleted file mode 100644 index 6a89aa635c..0000000000 --- a/docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md +++ /dev/null @@ -1,141 +0,0 @@ -# Wave 35 — `SendAsPeer.peer` `Peer` → `EnginePeer` - -Date: 2026-04-24 -Status: approved design, awaiting plan -Wave shape: Peer-typed-API single atomic commit (wave 34 pattern replayed on a smaller target) - -## Goal - -Eliminate the Postbox-protocol `Peer` leak in the public `SendAsPeer` struct by migrating its `peer` field from `Peer` to `EnginePeer`. Apply wave 34's lessons — comprehensive pre-flight grep including `.peer as?`/`is` casts, outflow-arg patterns, and loop-body `.peer` accesses — to keep post-commit build iterations low. - -## Non-goals - -- `ContactListPeer.peer(peer: Peer, ...)` case-payload migration — broader blast radius, deferred. -- `canSendMessagesToPeer(_:)` parameter migration — broader blast radius, deferred. -- `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` protocol-method migrations — broader blast radius, deferred. -- `CachedSendAsPeers` cache entry — already `PeerId`-based, entirely inside TelegramCore; no change needed. -- No new engine wrappers, typealiases, or facades introduced in this wave. - -## Type change - -```swift -// Before -public struct SendAsPeer: Equatable { - public let peer: Peer // Postbox protocol - public let subscribers: Int32? - public let isPremiumRequired: Bool - public init(peer: Peer, subscribers: Int32?, isPremiumRequired: Bool) { … } - public static func ==(lhs: SendAsPeer, rhs: SendAsPeer) -> Bool { - return lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers && lhs.isPremiumRequired == rhs.isPremiumRequired - } -} - -// After -public struct SendAsPeer: Equatable { - public let peer: EnginePeer // TelegramCore value type - public let subscribers: Int32? - public let isPremiumRequired: Bool - public init(peer: EnginePeer, subscribers: Int32?, isPremiumRequired: Bool) { … } - // Equatable synthesized — EnginePeer is Equatable. -} -``` - -## In-scope files - -### Category α — TelegramCore (definition + internal construction) - -**`submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift`** -- Lines 7–21: struct definition. Change `peer: Peer` → `peer: EnginePeer`. Remove manual `==`; rely on synthesized Equatable. -- Line 64 (`_internal_cachedPeerSendAsAvailablePeers`): `SendAsPeer(peer: peer, …)` — wrap raw Postbox `Peer` with `EnginePeer(peer)`. -- Line 170 (`_internal_peerSendAsAvailablePeers`): same wrap. -- Line 236 (`_internal_cachedLiveStorySendAsAvailablePeers`): same wrap. -- Line 330 (`_internal_liveStorySendAsAvailablePeers`): same wrap. -- Lines 87, 90, 259, 262: `peer.peer.id` accesses inside the caching loop — `EnginePeer.id` returns `EnginePeer.Id` which is a typealias for `PeerId`; code keeps compiling. - -No other TelegramCore files reference `SendAsPeer`. - -### Category β — Pure token/init/access (no body edits expected) - -**`submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift`** -- Line 553: `public let sendAsPeers: [SendAsPeer]?` — field typed at collection level, no `.peer` access in this file. -- Lines 751–752 / 848 / 1068 / 1408: init parameter, assignment, equality comparison at `[SendAsPeer]?` level, and `updatedSendAsPeers(_:)` method. None reference the inner `.peer` field. -- Expected edits: zero. This file should remain untouched if the field-type migration is clean. - -**`submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift`** -- Out of scope: its `openSendAsPeer: (ASDisplayNode, ContextGesture?) -> Void` callback does NOT take a `SendAsPeer`; name-collision only. - -### Category γ — Cast-downstream - -**`submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift`** -- Lines 20, 26: `peers: [SendAsPeer]` field and constructor — no edit needed. -- Lines 68–82: iteration body. - - Line 70: `peer.peer.id.namespace == Namespaces.Peer.CloudUser` — unchanged (EnginePeer.Id retains `.namespace`). - - Line 73: **`if let peer = peer.peer as? TelegramChannel`** → rewrite as `if case let .channel(channelData) = peer.peer`, matching on the `EnginePeer` enum case. Downstream `channelData.info` access behaves the same; `case .broadcast = channelData.info` continues to compile because `EnginePeer.channel` wraps the same `TelegramChannel.Info` enum. -- Lines 89 / 110 / 116 / 121: `EnginePeer(peer.peer)` — drop the wrap, use `peer.peer` directly. - -### Category δ — Outflow (construction and field access) - -**`submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift`** -- Line 772: `SendAsPeer(peer: peer._asPeer(), …)` — drop `._asPeer()`; construction now takes `EnginePeer` directly. `peer` at this site is already an `EnginePeer` upstream. -- Lines 805, 823: `SendAsPeer(peer: channel, …)` where `channel` is a raw `TelegramChannel` — wrap with `EnginePeer(channel)`. -- Lines 792 / 826 / 835 / 844: `allPeers` array ops and `.peer.id` filter/find — unchanged. - -**`submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift`** -- Line 847: `SendAsPeer(peer: sendAsConfiguration.currentPeer._asPeer(), …)` — drop `._asPeer()`. `sendAsConfiguration.currentPeer` is `EnginePeer` upstream. -- Line 851: `updatedSendAsPeers([…])` — unchanged. - -**`submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift`** -- Line 1625: `EnginePeer(peer)` where `peer` is now `EnginePeer` → collapses to `peer`. -- Lines 1616 / 1620 / 1622 / 2948 / 5370: `.peer.id` comparisons, `sendAsPeers.first(where:)` — unchanged. - -**`submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift`** -- Line 249: `SendAsPeer(peer: accountPeer._asPeer(), …)` — drop `._asPeer()`. -- Line 4080: `(sendAsPeer?.peer).flatMap(EnginePeer.init)` → simplifies to `sendAsPeer?.peer` (already `EnginePeer?`). -- Line 4081: `.map({ EnginePeer($0.peer) })` → `.map({ $0.peer })`. -- Line 254 / 688 / 701 / 702 / 705 / 4050 / 4068 / 4069 / 4088 / 4089 / 4327 / 4333 / 4340 / 4356 / 4372: `.peer.id` accesses, variable bindings, optional access — unchanged. -- Line 4340: `call.sendStars(fromId: sendAsPeer?.peer.id, …)` — `EnginePeer.Id == PeerId`, unchanged. - -**`submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift`** -- Lines 3056–3072: `sendMessageContext.currentSendAsPeer` pass-through to context-menu item. Verify call-site type expectations during implementation; likely no edit needed since `ChatSendAsPeerListContextItem` keeps taking `[SendAsPeer]`. - -## Out-of-scope — name collisions (do not touch) - -- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/LiveStreamSettingsScreen.swift:271-272` — `screenState.sendAsPeers` is `[EnginePeer]` (see `ShareWithPeersScreen.swift:1114`). Different type, same name. -- `submodules/TelegramUI/Components/Chat/ChatSendStarsScreen/Sources/ChatSendStarsScreen.swift:1515,2749,2958` — `availableSendAsPeers: [EnginePeer]` enum-case payload. Different type, same name. -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:7070`, `ShareWithPeersScreen.swift:39,57,74,817,1301,2352,3284,3453` — `initialSendAsPeerId: EnginePeer.Id?` / method names containing "SendAsPeer". PeerId parameter, not the struct. -- Callback declarations in `ChatPanelInterfaceInteraction.swift`, `AttachmentPanel.swift`, `PeerSelectionControllerNode.swift`, `ChatRecentActionsController.swift`, `PeerInfoSelectionPanelNode.swift` named `updateShowSendAsPeers` / `openSendAsPeer` — these take `(Bool)`/`(ASDisplayNode, ContextGesture?)`, not `SendAsPeer` values. - -## Execution plan outline (for writing-plans) - -Single atomic commit ordering: - -1. Edit `SendAsPeers.swift` — change field type, init parameter, drop manual `==`, wrap raw `Peer` at the 4 construction sites with `EnginePeer(peer)`. -2. Edit `ChatSendAsPeerListContextItem.swift` — rewrite line 73 cast to EnginePeer case match; drop `EnginePeer(peer.peer)` wraps at 89/110/116/121. -3. Edit `ChatControllerLoadDisplayNode.swift` — drop `._asPeer()` at 772; wrap `channel` with `EnginePeer(channel)` at 805/823. -4. Edit `ChatTextInputPanelComponent.swift` — drop `._asPeer()` at 847. -5. Edit `ChatTextInputPanelNode.swift` — collapse `EnginePeer(peer)` at 1625 to `peer`. -6. Edit `StoryItemSetContainerViewSendMessage.swift` — drop `._asPeer()` at 249; simplify flatMap at 4080; simplify map at 4081. -7. Verify `ChatPresentationInterfaceState.swift` and `StoryItemSetContainerComponent.swift` need no body edits. -8. Build: `source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError`. -9. Fix any files the inventory undercounted (expect scalar `.peer` accesses in closure bodies). Commit once build is green. - -## Risk register - -| Risk | Mitigation | -|------|------------| -| Inventory undercount (wave 34 lost ~30%) | Pre-flight grep already includes `.peer as?`/`is`/outflow; use `--continueOnError` on first build to surface all sites in one pass. | -| Cast at `ChatSendAsPeerListContextItem:73` doesn't round-trip | `EnginePeer.channel(TelegramChannel)` wraps the exact same concrete type; the `if case let .channel(ch)` rewrite preserves all `ch.info`/`ch.flags`/etc. semantics. | -| `SendAsPeer` Equatable synthesis regression | `EnginePeer` and `Int32?` and `Bool` are all Equatable; synthesized `==` produces the same truth table modulo replacing `Peer.isEqual` with `EnginePeer ==` (which for `.channel(a)` vs `.channel(b)` compares the underlying `TelegramChannel` via its own Equatable). No behavior change expected. | -| `StoryItemSetContainerComponent.swift:3056-3072` outflow missed | Plan step 7 verifies this during implementation; if a wrap/unwrap is needed at the context-menu boundary, add it inline. | - -## Validation - -- Full Bazel build (`--configuration=debug_sim_arm64 --continueOnError`). -- No TelegramCore/Postbox/TelegramApi errors (scope boundary check — halt if they surface). -- Grep post-commit: `rg "SendAsPeer\(peer: .*\._asPeer" submodules/` returns empty. -- Grep post-commit: `rg "EnginePeer\(.*\.peer\b" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu` returns empty. - -## Lessons to carry forward - -- Wave 34's grep pattern (``-literal token only) undercounted ~30%. This wave's Explore inventory explicitly included `.peer as?`/`is`/outflow-helper/`EnginePeer(.peer)` / `._asPeer()` patterns. Record the post-commit file count vs. pre-commit inventory to calibrate future Peer-typed-API waves. -- Name collisions (different types, same identifier) are a recurring scoping hazard — confirmed in this wave for `sendAsPeers: [EnginePeer]` and `availableSendAsPeers: [EnginePeer]`. Future Peer-typed-API waves should include a name-collision disambiguation pass during inventory. diff --git a/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md b/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md deleted file mode 100644 index 1d9a3fe296..0000000000 --- a/docs/superpowers/specs/2026-04-25-peerinfo-enclosingpeer-engine-peer.md +++ /dev/null @@ -1,127 +0,0 @@ -# Wave 50 — `enclosingPeer` Peer? → EnginePeer? - -**Date:** 2026-04-25 -**Pattern:** struct-field + stored-form `Peer?` → `EnginePeer?` (wave-47/48 shape). -**Module:** `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` only — no public-API leaks. - -## Goal - -Migrate the PeerInfo members chain's `enclosingPeer` field from raw Postbox `Peer?` to `EnginePeer?`. Drops 2 `_asPeer()` demotions, 1 `EnginePeer(...)` wrap, 1 `flatMap(EnginePeer.init)` simplification, and 1 PSPB boundary `_asPeer()` lift. Closes the wave-48-pattern internal-demotion-and-external-re-promotion ratchet at PIMP:354–363 (engine.data subscription returns `EnginePeer?`, currently demoted to `Peer?` at the storage boundary). - -## Type changes - -| File | Site | Before | After | -|---|---|---|---| -| `PeerInfoScreenMemberItem.swift:23` | stored `let enclosingPeer` | `Peer?` | `EnginePeer?` | -| `PeerInfoScreenMemberItem.swift:34` | init param | `Peer?` | `EnginePeer?` | -| `PeerInfoMembersPane.swift:92` | `func item(... enclosingPeer:)` | `Peer` | `EnginePeer` | -| `PeerInfoMembersPane.swift:271` | `func preparedTransition(... enclosingPeer:)` | `Peer` | `EnginePeer` | -| `PeerInfoMembersPane.swift:293` | `private var enclosingPeer` | `Peer?` | `EnginePeer?` | -| `PeerInfoMembersPane.swift:442` | `func updateState(enclosingPeer:)` | `Peer` | `EnginePeer` | - -`PeerInfoScreenMemberItem` and `PeerInfoMembersPaneNode` are local to the module — no cross-module signature ripple. - -## Edit patterns - -### A. Conditional cast → case-let (wave-41/45 idiom) - -| File:Line | Before | After | -|---|---|---| -| PSMI:152 | `if let channel = item.enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank)` | `if case let .channel(channel) = item.enclosingPeer, channel.hasPermission(.editRank)` | -| PSMI:154 | `else if let group = item.enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank)` | `else if case let .legacyGroup(group) = item.enclosingPeer, !group.hasBannedPermission(.banEditRank)` | -| PIMP:113 | `if let channel = enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank)` | `if case let .channel(channel) = enclosingPeer, channel.hasPermission(.editRank)` | -| PIMP:115 | `else if let group = enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank)` | `else if case let .legacyGroup(group) = enclosingPeer, !group.hasBannedPermission(.banEditRank)` | - -The `case let` pattern binds `channel: TelegramChannel` / `group: TelegramGroup` directly — `.hasPermission(.editRank)` and `.hasBannedPermission(.banEditRank)` are class methods on the bound concrete types. No `_asPeer()` bridge needed. - -### B. `is`-check → `case` (wave-41 always-false-warning fix) - -| File:Line | Before | After | -|---|---|---| -| PSMI:181 | `if actions.contains(.promote) && item.enclosingPeer is TelegramChannel` | `if actions.contains(.promote), case .channel = item.enclosingPeer` | -| PSMI:187 | `if item.enclosingPeer is TelegramChannel` | `if case .channel = item.enclosingPeer` | -| PIMP:142 | `if actions.contains(.promote) && enclosingPeer is TelegramChannel` | `if actions.contains(.promote), case .channel = enclosingPeer` | -| PIMP:148 | `if enclosingPeer is TelegramChannel` | `if case .channel = enclosingPeer` | - -PIMP:113/115/142/148 are inside `func item(... enclosingPeer: EnginePeer ...)`, so `enclosingPeer` is non-optional inside that body; PSMI sites are against `item.enclosingPeer: EnginePeer?`. `case let .channel(channel)` and `case .channel` both compile cleanly against optional and non-optional EnginePeer. - -### C. Drop wraps / unwraps - -| File:Line | Before | After | -|---|---|---| -| PSMI:178 | `peer: item.enclosingPeer.flatMap(EnginePeer.init)` | `peer: item.enclosingPeer` | -| PIMP:139 | `peer: EnginePeer(enclosingPeer)` | `peer: enclosingPeer` | -| PIMP:361 | `strongSelf.enclosingPeer = enclosingPeer._asPeer()` | `strongSelf.enclosingPeer = enclosingPeer` | -| PIMP:363 | `updateState(enclosingPeer: enclosingPeer._asPeer(), state: state, presentationData: presentationData)` | `updateState(enclosingPeer: enclosingPeer, state: state, presentationData: presentationData)` | -| PSPB:852 | `enclosingPeer: peer._asPeer()` | `enclosingPeer: peer` | - -### D. No-op call sites (type flows through transparently) - -- `PeerInfoSettingsItems.swift:132` — `enclosingPeer: nil` (nil literal works for any optional) -- `PeerInfoMembersPane.swift:275/276` — pass-through `enclosingPeer: enclosingPeer` -- `PeerInfoMembersPane.swift:437/438` — `if let enclosingPeer = self.enclosingPeer ... self.updateState(enclosingPeer: enclosingPeer, ...)` (both stored-form and `updateState` param shift to EnginePeer; type carries through) -- `PeerInfoMembersPane.swift:451` — pass-through -- `PeerInfoMembersPane.swift:485` — `self.enclosingPeer = enclosingPeer` (param and stored-form both EnginePeer) -- `PeerInfoScreenOpenMember.swift` — uses `self.data?.peer` (already `EnginePeer?` post-wave-42), unrelated to this migration - -**Total edits:** 19 across 3 files (PSMI, PIMP, PSPB) — 6 type-change edits in the table at the top of this spec + 4 (Pattern A) + 4 (Pattern B) + 5 (Pattern C). - -## Risk register - -| Risk | Mitigation | -|---|---| -| `case .channel = item.enclosingPeer` against `EnginePeer?` semantics | Wave-45 lesson confirms `case let .x(y) = peer` compiles cleanly against `EnginePeer?`. Matches `.some(.channel)`, rejects `nil` and other cases — equivalent to `is TelegramChannel` semantics. | -| `if actions.contains(.promote), case .channel = ...` mixed boolean + pattern condition | Standard Swift if-case syntax (introduced in wave 41 idiom for this codebase). | -| Hidden Peer-only property access on bare `enclosingPeer` | Pre-flight grep complete: only access patterns are `.id` (EnginePeer has it), and cast-bound `channel.hasPermission` / `group.hasBannedPermission`. No `_asPeer()` bridges expected. | -| Closure capture aliases (wave-47 lesson) | Pre-flight grep covered `strongSelf.enclosingPeer` (PIMP:361) and `self.enclosingPeer` (PIMP:437/485). | -| `enclosingPeer: nil` literal at PSI:132 | `nil` is valid for any optional — no edit. | -| `availableActionsForMemberOfPeer` signature compatibility | Confirmed `EnginePeer?` at `PeerInfoData.swift:2314`. Both PSMI:178 and PIMP:139 are pure simplifications. | -| Always-false `is` check warning under `-warnings-as-errors` | Wave-41 lesson — handled by Pattern B. | - -## Wave shape - -**Classification:** cross-file private struct-field migration with stored-form ratchet (wave-47 taxonomy: "cross-file private"). -**Iteration budget:** 1–2 (target first-pass-clean per wave 48/49 streak). -**Subagent dispatch:** not needed — 17 edits / 3 files is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -### Post-edit residue grep (expect empty) - -```sh -grep -rnE "enclosingPeer\._asPeer|EnginePeer\(enclosingPeer\)|enclosingPeer\.flatMap\(EnginePeer" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ - -grep -rnE "enclosingPeer.*as\? TelegramChannel|enclosingPeer.*as\? TelegramGroup|enclosingPeer is TelegramChannel" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ -``` - -## Net delta projection - -- **Internal bridges:** −5 (2× `_asPeer()` at PIMP:361/363, 1× `EnginePeer(...)` at PIMP:139, 1× `flatMap(EnginePeer.init)` at PSMI:178, 1× boundary `_asPeer()` at PSPB:852). -- **Boundary lifts:** 0 net new — the source pipeline (engine.data subscription at PIMP:354) already yields `EnginePeer?`. Migration just removes the demote-then-promote dance. -- **ADD wraps:** 0 expected (no Peer-only property accesses on bare `enclosingPeer`). - -## Out of scope - -- `PeerInfoScreenData.chatPeer: Peer?` — large cascade (PSPB `as? TelegramX` × 5, ClearPeerHistory cascade, openClearHistory wraps × 4, PSOC × 2). Memory's wave-50 candidate Option 3, deferred for a multi-iteration wave. -- `PeerInfoGroupsInCommonPaneNode.PeerEntry.peer: Peer` — separate single-file migration, not bundled (wave-49 source-of-truth-coherence rule: unrelated chains stay in their own waves). Candidate for wave 51. -- `RenderedPeer → EngineRenderedPeer` foundational refactor — dedicated session. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Move wave 50 outcome into the recent-waves list. -- Promote wave 51 candidate (`PeerInfoGroupsInCommonPaneNode.PeerEntry.peer` likely; otherwise re-scan the module with the standard grep). diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md b/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md deleted file mode 100644 index 5d7800d814..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node-design.md +++ /dev/null @@ -1,120 +0,0 @@ -# Wave 103 — `ChatRecentActionsControllerNode.peer: Peer → EnginePeer` - -**Date:** 2026-04-26 -**Pattern:** close-the-shadow boundary unwrap drop (wave-71-shadow). Single-file private stored-field migration with caller-side `_asPeer()` removal at the module boundary. -**Module:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/` only — no public-API leak. - -## Goal - -Migrate `ChatRecentActionsControllerNode`'s stored `peer: Peer` to `EnginePeer`, dropping the `_asPeer()` boundary call inside `ChatRecentActionsController`. Net effect: −1 `_asPeer()` boundary wrap, −1 `import Postbox`, −1 module from the Postbox-importing list. - -The caller (`ChatRecentActionsController`) already holds `peer: EnginePeer` and demotes it once at line 277 before passing into the ControllerNode init. This is the wave-71-shadow shape: the public API is already `EnginePeer`, but a private internal storage form was left as `Peer` at wave-71 time. Closing it now is a clean, contained migration. - -## Type changes - -| File | Site | Before | After | -|---|---|---|---| -| `ChatRecentActionsControllerNode.swift:46` | stored `private let peer` | `Peer` | `EnginePeer` | -| `ChatRecentActionsControllerNode.swift:111` | init param `peer:` | `Peer` | `EnginePeer` | -| `ChatRecentActionsControllerNode.swift:5` | `import Postbox` | present | removed | -| `ChatRecentActionsController.swift:277` | call `peer: self.peer._asPeer()` | demoted | `peer: self.peer` | - -`ChatRecentActionsControllerNode` has no public-API consumers outside `ChatRecentActionsController` (single caller site verified by grep `ChatRecentActionsControllerNode\(`). - -## Edit patterns - -### A. Conditional cast → case-let (wave-41/45 idiom) - -| File:Line | Before | After | -|---|---|---| -| ChatRecentActionsControllerNode.swift:899 | `if let peer = strongSelf.peer as? TelegramChannel { ... }` | `if case let .channel(peer) = strongSelf.peer { ... }` | -| ChatRecentActionsControllerNode.swift:948 | `if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { ... }` | `if case let .channel(channel) = self.peer, case .broadcast = channel.info { ... }` | -| ChatRecentActionsControllerNode.swift:1088 | `if let channel = self.peer as? TelegramChannel { ... }` | `if case let .channel(channel) = self.peer { ... }` | - -The `case let .channel(channel)` pattern binds `channel: TelegramChannel` directly. Inner code (`channel.info`, etc.) ports verbatim because `EnginePeer.channel`'s associated value is the concrete `TelegramChannel` class. - -`self.peer` is non-optional `EnginePeer` post-migration, so all three case-let conditions compile cleanly without optional-chaining. - -### B. Pass-through (no edit, type flows transparently) - -- `self.peer.id` — 4 sites (lines 145, 161, 1138, 1490). `EnginePeer.id` is an `EnginePeer.Id` typealias of `PeerId`, identical at the call sites that consume it (`channelAdminEventLog(peerId:)`, `admins(peerId:)`, `updateChannelMemberBannedRights(peerId:)`, et al. all accept the typealiased form). - -### C. Caller boundary drop - -| File:Line | Before | After | -|---|---|---| -| ChatRecentActionsController.swift:277 | `ChatRecentActionsControllerNode(... peer: self.peer._asPeer(), ...)` | `ChatRecentActionsControllerNode(... peer: self.peer, ...)` | - -`ChatRecentActionsController.peer` is already declared `EnginePeer` (init signature at line 42 confirmed). - -**Total edits:** 7 across 2 files. 4 type-change edits (3 in node + 1 caller) + 3 case-let rewrites. - -## Risk register - -| Risk | Mitigation | -|---|---| -| Other unrelated `_asPeer()` and `EnginePeer(peer)` sites in the same file (lines 357, 368, 1005 / 263, 1009, 1011, 1208, 1222) | Pre-flight grep verified these all operate on DIFFERENT `peer` locals (callback-bound search results, not `self.peer`). They are unaffected by this migration. | -| Hidden `Peer`-only property access on `self.peer` | Pre-flight grep complete: only attribute access is `.id` (EnginePeer-compatible). 3 `as? TelegramChannel` downcasts are the only conversion sites, all handled by Pattern A. | -| `as? TelegramGroup` or `as? TelegramUser` downcasts on `self.peer` | None present (verified by grep `self\.peer as\?` returning only the 3 TelegramChannel sites). | -| `is TelegramChannel`-style always-false warning under `-warnings-as-errors` | None present (no `is`-checks on `self.peer` — verified by grep). | -| Closure capture alias migration (wave-47 lesson) | Only `strongSelf.peer` and `self.peer` aliases — both ride the type change. No locally-bound `let peer = self.peer` aliases that would need separate type-flow tracking (verified by grep). | -| Caller side-effects from `_asPeer()` removal | `ChatRecentActionsController.swift:277` is the only call site (verified). The `_asPeer()` is pure conversion with no side effects. | -| Build cascade beyond the two files | Consumer-only — both files are inside `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/`. No TelegramCore touch, no cross-module ripple. Build cost ~25s. | - -## Wave shape - -**Classification:** wave-71-shadow close (single-file private stored-form migration with single-caller boundary drop). -**Iteration budget:** 1 (target first-pass-clean given the contained scope and validated pre-flight grep). -**Subagent dispatch:** not needed — 7 edits across 2 files is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -(No `--continueOnError` — single-iter target with small scope.) - -### Post-edit residue grep (expect empty) - -```sh -# No remaining as? TelegramChannel on self.peer / strongSelf.peer -grep -nE "(self|strongSelf)\.peer as\? Telegram(Channel|Group|User)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ - -# No remaining _asPeer() on self.peer -grep -nE "self\.peer\._asPeer\(\)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ - -# No remaining import Postbox in the module -grep -rn "^import Postbox$" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ -``` - -## Net delta projection - -- **Internal bridges:** −1 (the `_asPeer()` at `ChatRecentActionsController.swift:277`). -- **`import Postbox` drops:** −1 (`ChatRecentActionsControllerNode.swift:5`). -- **ADD wraps:** 0 (no Peer-only property accesses on bare `self.peer`). -- **Module Postbox-free count:** +1. - -## Out of scope - -- Other `Peer`-typed locals in the same file (search-callback-bound `peer` at lines 357, 368, 1005, etc.) — these belong to separate signatures (`Signal`, search result destructures from APIs that still return raw `Peer`). Migrating them is gated on those upstream APIs migrating first. -- `context.account.postbox.network` and similar Shape-D Postbox accesses — unrelated to this wave's `peer` field migration. -- `EnginePeer(peer)` boundary wraps inside callbacks (lines 263, 1009, 1011, 1208, 1222) — these wrap callback-bound search results, not `self.peer`. Out of scope for the same reason as above. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Move wave 103 outcome into the recent-waves list (commit hash + 7-edit single-iter summary). -- Update the "Wave 103+ Shape-C/D candidates" line in `MEMORY.md` since this is technically a wave-71-shadow close, not a Shape-C/D refactor — the candidates listed there (NativeVideoContent, DirectMediaImageCache, SecureIdDocumentFormControllerNode) carry forward to wave 104+. -- The `ChatRecentActionsControllerNode.peer: Peer -> EnginePeer` candidate line in the next-wave file (currently bullet 5) gets removed. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md b/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md deleted file mode 100644 index bc24de86be..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md +++ /dev/null @@ -1,149 +0,0 @@ -# Wave 103 (retry) — accountManager.mediaBox.storeResourceData drain - -**Date:** 2026-04-26 -**Pattern:** wave-shape-G drain of an existing TelegramCore facade (the wave-94 `AccountManagerResources.storeResourceData(id:data:synchronous:)`). -**Module:** `submodules/TelegramUI/Sources/ThemeUpdateManager.swift` + `submodules/WallpaperResources/Sources/WallpaperResources.swift` only — no TelegramCore touch, no public-API change. - -## Goal - -Drain the 5 remaining `accountManager.mediaBox.storeResourceData(...)` Shape-A sites that the wave-94/95-99 sweep didn't catch. Migrate each to `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(...), data: ..., synchronous: ...)` against the existing wave-94 facade. Net effect: −5 raw `accountManager.mediaBox.X` accesses, +5 facade calls. Consumer-only build. - -This is the wave-103 retry after the abandonment of `ChatRecentActionsControllerNode.peer` migration (see `postbox-refactor-log.md` "Wave 103 outcome (2026-04-26): ABANDONED"). - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Applicable? | Notes | -|---|---|---| -| 1. Downcasts (`as?` / `is`) | N/A | No Peer migration, no type-level change | -| 2. Peer-protocol extension method calls | N/A | No stored field retype | -| 3. Field flow into Peer-typed function parameters | N/A | No `peer` param involved | -| 4. Message-builder cascade via `SimpleDictionary` | N/A | No `Message(...)` construction touched | - -Wave shape (call-site rewrite against an existing facade) is orthogonal to the wave-71-shadow risk layers. The wave-94 lesson and wave-shape-G recipe are the relevant precedents. - -## Sites (5 total) - -### ThemeUpdateManager.swift (1 site) - -| Line | Existing | Migrated | -|---|---|---| -| 112 | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true)` | - -`accountManager` flows from the enclosing `presentationThemeSettingsUpdated(_:)` method's closure-captured scope. `accountManager: AccountManager` typed (Shape-A). - -### WallpaperResources.swift (4 sites) - -All four sites use the same call-text pattern (same arity, no `synchronous:` arg) but different argument expressions: - -| Line | Argument expression | -|---|---| -| 973 | `reference.resource.id, data: data` | -| 1214 | `reference.resource.id, data: data` | -| 1260 | `file.file.resource.id, data: fullSizeData` | -| 1523 | `file.file.resource.id, data: fullSizeData` | - -Lines 973 and 1214 share identical text (`accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)`) — `Edit replace_all=true` bundles them. Lines 1260 and 1523 share identical text (`accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)`) — same. - -Each migrated to: `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(), data: )`. - -`accountManager` flows from `wallpaperDatas(account:accountManager:...)` and other public functions in the file, all parameter-typed `AccountManager` (Shape-A). - -## Edit patterns - -### A. ThemeUpdateManager (1 site) - -Single Edit: - -| File:Line | Before | After | -|---|---|---| -| ThemeUpdateManager.swift:112 | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true)` | - -### B. WallpaperResources (4 sites in 2 replace_all batches) - -Two `Edit` calls, each with `replace_all=true`: - -| Pattern | Before | After | -|---|---|---| -| Pattern 1 (lines 973, 1214) | `accountManager.mediaBox.storeResourceData(reference.resource.id, data: data)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data)` | -| Pattern 2 (lines 1260, 1523) | `accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData)` | `accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData)` | - -**Total edits:** 3 Edit calls (1 single + 2 replace_all batches), 5 sites migrated. - -## Facade signature reference - -From `submodules/TelegramCore/Sources/AccountManager/AccountManagerResources.swift` (added wave 94): - -```swift -public func storeResourceData(id: EngineMediaResource.Id, data: Data, synchronous: Bool = false) { - self.mediaBox.storeResourceData(MediaResourceId(id.stringRepresentation), data: data, synchronous: synchronous) -} -``` - -`EngineMediaResource.Id(_ id: MediaResourceId)` constructor at `TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:179`. - -`accountManager.resources` is a computed property that constructs a fresh `AccountManagerResources` wrapper holding only a `MediaBox` reference — cheap. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `replace_all=true` matching the wrong site | Two patterns are scoped narrowly enough (full call expressions including the closing paren). Pre-flight grep confirmed exactly 2 instances of each pattern across the file. | -| `EngineMediaResource.Id(...)` constructor missing for the argument expression's type | Verified: `init(_ id: MediaResourceId)` exists. `MediaResource.id` returns `MediaResourceId` per Postbox protocol. Construction is canonical. | -| `synchronous:` default mismatch | Facade default is `synchronous: false`, matching `MediaBox.storeResourceData`'s underlying default. Sites without explicit `synchronous:` keep behavior. | -| Build cascade beyond touched files | Consumer-only — both files are leaf consumers (no public re-export of touched symbols). No TelegramCore touch. WallpaperResources is foundational so its rebuild fans out, but the public API is unchanged so dependent modules don't need recompilation. | -| WIP-interference at staging | Pre-existing WIP markers (`build-system/bazel-rules/sourcekit-bazel-bsp` + 3 untracked dirs) are in unrelated paths — no overlap. Stage by explicit file list. | - -## Wave shape - -**Classification:** wave-shape-G drain of an existing TelegramCore facade (waves 84-93 cohort, validated wave 94 + waves 95-99 drains). -**Iteration budget:** 1 (target first-pass-clean given mechanical scope and 5-site footprint). -**Subagent dispatch:** not needed — 3 Edit calls is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -(No `--continueOnError` — small atomic scope.) WallpaperResources is a foundational submodule with a wide rebuild fan-out, but its public API is unchanged so dependents don't recompile. Build cost projection: ~30-60s. - -### Post-edit residue grep (expect empty) - -```sh -grep -rn "accountManager\.mediaBox\.storeResourceData" \ - submodules/TelegramUI/Sources/ThemeUpdateManager.swift \ - submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: empty output across both files. - -## Net delta projection - -- **Raw `mediaBox.X` accesses:** −5 -- **Facade `resources.X` calls:** +5 -- **`EngineMediaResource.Id(...)` wraps:** +5 (these are canonical engine-side constructs, not Postbox bridges — they don't count as `_asPeer()`-style ADD wraps) -- **`import Postbox` drops:** 0 (both files retain `import Postbox` for unrelated symbols — this wave doesn't promise an import drop) -- **Postbox-free module count:** 0 net change - -## Out of scope - -- 7 `accountManager.mediaBox.resourceData(...)` sites — could use the existing `AccountManagerResources.data(resource:)` facade or a future `data(id:)` facade. Defer to a future drain wave. -- 22 `accountManager.mediaBox.cachedResourceRepresentation(...)` sites — Option A holdover, blocked by `CachedMediaResourceRepresentation` Postbox protocol leak. Needs facade-design pass. -- 3 `accountManager.mediaBox.storeCachedResourceRepresentation(...)` sites — same blocker. -- 2 `accountManager.mediaBox.cachedRepresentationCompletePath(...)` sites — same blocker. -- The `accountManager.mediaBox.cachedResourceRepresentation(...)` call at WallpaperResources:1261 and :1524 — directly adjacent to two of our migrated sites but blocked. Leave in place; the migrated `storeResourceData` call directly above it does not depend on it. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 103 (retry) outcome line into the recent-waves section. -- Mark the 5 sites as drained; remove from candidate inventories. -- Promote the next candidate (likely the 7-site `resourceData` drain or one of the foundational waves). diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md b/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md deleted file mode 100644 index defde5f1a7..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md +++ /dev/null @@ -1,148 +0,0 @@ -# Wave 104 — accountManager.mediaBox.resourceData drain (3 clean sites) - -**Date:** 2026-04-26 -**Pattern:** wave-shape-G drain of an existing TelegramCore facade (the wave-32 / wave-94 `AccountManagerResources.data(resource:pathExtension:waitUntilFetchStatus:attemptSynchronously:)`) with a documented field rename at consumer sites (`.complete` → `.isComplete`). -**Module:** `submodules/WallpaperResources/Sources/WallpaperResources.swift` only. - -## Goal - -Drain 3 of 8 `accountManager.mediaBox.resourceData(...)` Shape-A sites against the existing facade. Net effect: −3 raw `accountManager.mediaBox.X` accesses, +3 facade calls, +3 `EngineMediaResource(...)` wraps, +3 consumer `.complete` → `.isComplete` renames. - -The remaining 5 sites are deferred: 2 (`FetchCachedRepresentations.swift:482, 490`) flow `data: MediaResourceData` into `fetchCachedScaledImageRepresentation` / `fetchCachedBlurredWallpaperRepresentation` — both expect raw Postbox `MediaResourceData`, so migration would force a cascade or boundary reconstruction. 3 (`WallpaperResources.swift:33, 59, 401`) are coupled to postbox-side via `combineLatest(accountManager.mediaBox.resourceData, account.postbox.mediaBox.resourceData)` returning typed `Signal<(MediaResourceData, MediaResourceData), NoError>` — migrating one side without the other breaks the tuple type. - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Applicable? | Notes | -|---|---|---| -| 1. Downcasts | N/A | No type-level migration | -| 2. Peer-protocol extension method calls | N/A | Not a peer migration | -| 3. Field flow into Peer-typed function parameters | Adapted: result-type flow into MediaResourceData-typed params | **Cleared:** all 3 sites consume `maybeData.complete` and `maybeData.path` inline within the closure — no flow-out to functions taking raw `MediaResourceData`. Sites that DO flow out (482/490) are deferred. | -| 4. Message-builder cascade | N/A | No `Message(...)` construction touched | - -The "data: MediaResourceData" parameter at `fetchCachedScaledImageRepresentation:311` / `fetchCachedBlurredWallpaperRepresentation:453, 502` is the analogue of the wave-103 `Message.peers` constructor barrier — a Postbox-typed function-parameter barrier that forces ADD bridges if upstream migrates. The 3 chosen sites do not cross this barrier; the deferred 2 sites do. - -## Sites (3 total) - -### Call rewrites - -| Line | Existing call | Migrated call | -|---|---|---| -| 957 | `let maybeFetched = accountManager.mediaBox.resourceData(reference.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)` | `let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(reference.resource), attemptSynchronously: synchronousLoad)` | -| 1164 | `let maybeFetched = accountManager.mediaBox.resourceData(fileReference.media.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad)` | `let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(fileReference.media.resource), attemptSynchronously: synchronousLoad)` | -| 1264 | `return accountManager.mediaBox.resourceData(file.file.resource)` | `return accountManager.resources.data(resource: EngineMediaResource(file.file.resource))` | - -**`waitUntilFetchStatus: false` is omitted** in the migrated form — the facade signature has `waitUntilFetchStatus: Bool = false` as default. Sites 957/1164 explicitly pass `false`; site 1264 uses the underlying default. - -### Consumer-side renames (`.complete` → `.isComplete`) - -| Line | Existing | Migrated | -|---|---|---| -| 961 | ` if maybeData.complete {` | ` if maybeData.isComplete {` | -| 1168 | ` if maybeData.complete && isSupportedTheme {` | ` if maybeData.isComplete && isSupportedTheme {` | -| 1266 | ` if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {` | ` if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {` | - -### Sites NOT migrated (deferred) - -- L33, L59, L401 — `combineLatest(accountManager.mediaBox.resourceData(X), account.postbox.mediaBox.resourceData(X))` (typed tuple return) -- L482, L490 (in FetchCachedRepresentations.swift, not WallpaperResources.swift) — `data: MediaResourceData` flow-out cascade -- The closure bodies at sites 957, 1164 contain INNER `account.postbox.mediaBox.resourceData(...)` calls and inner `data.complete` accesses on a different binding (the postbox-side result) — those stay raw and are NOT touched by this wave. Only the OUTER `maybeFetched`-typed result is migrated. - -## Type reference - -Facade signature (existing, wave-32 / wave-94): - -```swift -public func data( - resource: EngineMediaResource, - pathExtension: String? = nil, - waitUntilFetchStatus: Bool = false, - attemptSynchronously: Bool = false -) -> Signal -``` - -`EngineMediaResource.ResourceData` (final class at `TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:149`): -- `public let path: String` (matches `MediaResourceData.path`) -- `public let availableSize: Int64` -- `public let isComplete: Bool` (renamed from `MediaResourceData.complete`) - -`EngineMediaResource(_ resource: MediaResource)` constructor — canonical wrap (CLAUDE.md cheat sheet). - -## Edit patterns - -6 separate Edit calls in 1 file. No `replace_all=true` opportunity — each call/rename has unique surrounding text. - -**Order (recommended):** call rewrites first (3 edits), then consumer renames (3 edits). This sequence keeps the file in a half-migrated but compilable state between batches if interrupted (call site uses new facade, consumer still on old field name → swift compile error caught quickly). - -Alternative order: per-site bundled (call + rename pair, then next pair) — also fine. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `EngineMediaResource(rawResource)` constructor missing | Verified: constructor exists per CLAUDE.md cheat sheet ("EngineMediaResource(rawResource) — wrap a raw MediaResource"). | -| `.path` field mismatch | Verified: both `MediaResourceData.path` and `EngineMediaResource.ResourceData.path` are `String`. No edit needed at any `data.path` usage site. | -| `.availableSize` not exposed by `MediaResourceData` | None of the 3 consumers use `.availableSize`. Only `.complete` (renamed) and `.path` (unchanged) are used. | -| Inner `data.complete` accesses on postbox-side bindings get renamed by accident | The 3 renames are on distinct bindings (`maybeData`, `maybeData`, `data`) within distinct outer scopes. The inner `data.complete` at L968 (postbox-side closure body inside site 957) is on a DIFFERENT `data` binding — its surrounding text differs (`return data.complete ? try? Data(...)` vs the migrated `if data.complete, let imageData = try? Data(...)`). Each Edit's `old_string` includes enough surrounding text to disambiguate. | -| `Signal.complete()` confused with field rename | The renames target `.complete` (property access). `Signal.complete()` is a method call, syntactically distinct (`return .complete()`). No regex collision. | -| `attemptSynchronously: synchronousLoad` arg flows | Facade exposes `attemptSynchronously: Bool = false`. Site 957/1164 pass `synchronousLoad` (a function param of the same name, Bool-typed) — flows through unchanged. | -| Build cascade beyond touched file | WallpaperResources is foundational with wide rebuild fan-out, but the public API is unchanged so dependents don't recompile. Build cost projection: ~30-60s. | - -## Wave shape - -**Classification:** wave-shape-G drain of an existing TelegramCore facade with a documented consumer field rename. Mid-difficulty between wave-103-retry (pure mechanical) and wave-71-shadow (cascade-prone). -**Iteration budget:** 1 (target first-pass-clean given small footprint and verified pre-flight inventory). -**Subagent dispatch:** not needed — 6 edits in 1 file is single-implementer scope. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -(No `--continueOnError` — small atomic scope.) Build cost projection: ~30-60s. - -### Post-edit residue grep (expect specific output, NOT empty) - -```sh -grep -rn "accountManager\.mediaBox\.resourceData" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: 3 lines remaining (L33, L59, L401 — the deferred combineLatest sites). Sites 957, 1164, 1264 should NOT appear. - -```sh -grep -nE "maybeData\.complete\b|^.*\bdata\.complete\b" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: a small number of lines remaining at unrelated sites (the postbox-side inner closure at site-957's L968 still uses `data.complete` on a postbox-side binding — that stays). Lines 961, 1168, 1266 should NOT appear. - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| Raw `mediaBox.resourceData` accesses dropped | −3 | WR:957, 1164, 1264 | -| Facade calls added | +3 | same sites, migrated form | -| `EngineMediaResource(...)` wraps added | +3 | canonical engine-side wraps, not Postbox bridges | -| Consumer `.complete` → `.isComplete` renames | +3 | WR:961, 1168, 1266 | -| `import Postbox` drops | 0 | WallpaperResources retains Postbox import for unrelated symbols | -| Postbox-free module count | 0 | unchanged | - -## Out of scope - -- Sites 482/490 in FetchCachedRepresentations.swift — `data: MediaResourceData` cascade through `fetchCachedScaled*Representation` family. Defer to a session that designs the appropriate facade or migrates the cascade as a co-wave. -- Sites 33/59/401 in WallpaperResources.swift — `combineLatest(accountManager.mediaBox.resourceData, account.postbox.mediaBox.resourceData)` typed-tuple coupling. Defer until postbox-side `account.postbox.mediaBox.resourceData` is also drainable (Shape-C territory) or a paired-resource facade is designed. -- The 22 `cachedResourceRepresentation` accountManager-side sites — blocked by `CachedMediaResourceRepresentation` Postbox protocol leak. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 104 outcome line into the recent-waves section. -- Update accountManager-side facade drain status table: `resourceData` count drops from 8 → 5 (3 drained, 5 deferred). -- Note the `fetchCachedScaled*Representation` cascade barrier — adds it to the list of "Postbox-typed-function-parameter barriers" alongside `Message.peers: SimpleDictionary`. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md b/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md deleted file mode 100644 index 5147af412a..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md +++ /dev/null @@ -1,183 +0,0 @@ -# Wave 105 — DeviceContactInfoSubject enum payload Peer? → EnginePeer? - -**Date:** 2026-04-26 -**Pattern:** Multi-module enum-payload migration with completion-callback signature change (wave-91 shape — `ItemListWebsiteItem.peer + RecentSessionsController.website case payload + openWebSession callback`). -**Modules:** `AccountContext` (enum + computed property), `PeerInfoUI` (`DeviceContactInfoController.swift` primary consumer), `TelegramUI` (4 construction sites across 4 files). - -## Goal - -Migrate `DeviceContactInfoSubject` enum's 3 case payloads from `Peer?` to `EnginePeer?`, plus 2 callback signatures (`.filter`'s `(Peer?, DeviceContactExtendedData) -> Void` and `.create`'s `(Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void`) and the public `peer: Peer?` computed property. Net effect: −10 wraps dropped, +2 wraps added (Chat-side construction barriers), +1 `as? TelegramUser` → `case let .user(...)` rewrite. **Net wrap delta: −8.** - -## Wave-71-shadow risk inventory (per `feedback_wave71_shadow_risk.md`) - -| Layer | Result | Notes | -|---|---|---| -| 1. Downcasts (`as?` / `is`) on the migrated value | **1 site** | `DeviceContactInfoController.swift:849` — `if let peer = peer as? TelegramUser` becomes `if case let .user(peer) = peer`. Inner body accesses `peer.firstName`, `peer.lastName`, `peer.phone` — all `TelegramUser` fields, work after rebinding via case-let. | -| 2. Peer-protocol extension method calls | **0 blockers** | Inventory pass found ALL consumer-side access on the migrated bindings is `.id` only. No `Peer`-protocol-only methods (no `canSetupAutoremoveTimeout`, `displayTitle`, `addressName`, etc. on the migrated bindings). | -| 3. Field flow into `Peer`-typed function parameters | **2 ADD bridges** | `ChatControllerOpenAttachmentMenu.swift:683` and `:1850` — both pass `peerAndContactData.0` directly to `.filter(peer:)` constructor. The upstream signal type is explicitly `(Peer?, DeviceContactExtendedData?)` (see L634, L1822). After migration, the construction must wrap: `peerAndContactData.0.flatMap(EnginePeer.init)`. **Accepted barrier — net-negative wave delta still wins.** | -| 4. `Message`-builder / `SimpleDictionary` barriers | **0** | No `Message(...)` constructor calls or dict-store patterns on the migrated bindings. | - -The 2 ADD bridges in Layer 3 are the only wave cost; net delta after accounting for them is still −8. - -## Type changes - -### AccountContext.swift (lines 703-718) - -| Line | Before | After | -|---|---|---| -| 704 | `case vcard(Peer?, DeviceContactStableId?, DeviceContactExtendedData)` | `case vcard(EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData)` | -| 705 | `case filter(peer: Peer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (Peer?, DeviceContactExtendedData) -> Void)` | `case filter(peer: EnginePeer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (EnginePeer?, DeviceContactExtendedData) -> Void)` | -| 706 | `case create(peer: Peer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)` | `case create(peer: EnginePeer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (EnginePeer?, DeviceContactStableId, DeviceContactExtendedData) -> Void)` | -| 708 | `public var peer: Peer? {` | `public var peer: EnginePeer? {` | - -The `contactData: DeviceContactExtendedData` computed property at L719 is unchanged. - -## Edit patterns - -### Pattern A — `_asPeer()` drops at construction sites (5 sites) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:1289 | `subject: .vcard(peer?._asPeer(), contactId, contactData)` | `subject: .vcard(peer, contactId, contactData)` | -| DeviceContactInfoController.swift:1443 | `subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in` | `subject: .create(peer: peer, contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in` | -| DeviceContactInfoController.swift:1489 | `subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in` | `subject: .create(peer: peer, contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in` | -| StoryItemSetContainerViewSendMessage.swift:2132 | `subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in` | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in` | -| OpenChatMessage.swift:443 | `subject: .vcard(peer?._asPeer(), nil, contactData)` | `subject: .vcard(peer, nil, contactData)` | - -Each source `peer` is already `EnginePeer?` (verified per-site: line 1289 in `addContactToExisting` callback already typed `(EnginePeer?, ...)` at L1409; line 1443 in `dataSignal` callback that returns `(EnginePeer?, ...)`; line 1489 in `addContactOptionsController(peer: EnginePeer?, ...)`; line 2132 in `(EnginePeer?, ...)` signal; line 443 in `peer: EnginePeer?` source). - -### Pattern B — `_asPeer()` drops at completion-call sites (2 sites) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:1105 | `completion(peerAndContactData.0?._asPeer(), filteredData)` | `completion(peerAndContactData.0, filteredData)` | -| DeviceContactInfoController.swift:1224 | `completion(contactIdAndData.2?._asPeer(), contactIdAndData.0, contactIdAndData.1)` | `completion(contactIdAndData.2, contactIdAndData.0, contactIdAndData.1)` | - -The completion's first parameter type changes from `Peer?` to `EnginePeer?` per the enum migration. Source values (`peerAndContactData.0` and `contactIdAndData.2`) are already `EnginePeer?` (typed signal pipelines), so dropping `_asPeer()` is a clean simplification. - -### Pattern C — `.flatMap(EnginePeer.init)` simplifications (3 sites) - -DeviceContactInfoController.swift:941-946 region: - -| File:Line | Before | After | -|---|---|---| -| 941-942 | `case let .vcard(peer, id, data):\n contactData = .single((peer.flatMap(EnginePeer.init), id, data))` | `case let .vcard(peer, id, data):\n contactData = .single((peer, id, data))` | -| 943-944 | `case let .filter(peer, id, data, _):\n contactData = .single((peer.flatMap(EnginePeer.init), id, data))` | `case let .filter(peer, id, data, _):\n contactData = .single((peer, id, data))` | -| 945-946 | `case let .create(peer, data, share, shareViaExceptionValue, _):\n contactData = .single((peer.flatMap(EnginePeer.init), nil, data))` | `case let .create(peer, data, share, shareViaExceptionValue, _):\n contactData = .single((peer, nil, data))` | - -After migration, the destructured `peer: EnginePeer?` is already the target type — the `.flatMap(EnginePeer.init)` round-trip becomes redundant. - -### Pattern D — Downcast → case-let (1 site) - -| File:Line | Before | After | -|---|---|---| -| DeviceContactInfoController.swift:849 | `if let peer = peer as? TelegramUser {` | `if case let .user(peer) = peer {` | - -The outer `peer` is bound from `case let .create(peer, contactData, _, _, _) = subject` at L845, which becomes `EnginePeer?` post-migration. `case let .user(peer) = peer` rebinds the inner `peer` to `TelegramUser` (the `.user` case associated value). Inner body accesses `peer.firstName`, `peer.lastName`, `peer.phone` — all `TelegramUser` instance methods/properties, work transparently after rebinding. - -### Pattern E — ADD wraps at Chat-side construction (2 sites) - -| File:Line | Before | After | -|---|---|---| -| ChatControllerOpenAttachmentMenu.swift:683 | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in` | `subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in` | -| ChatControllerOpenAttachmentMenu.swift:1850 | `subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in` | `subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in` | - -Both sites have identical text — `Edit replace_all=true` bundles them. The upstream signal type is explicitly `(Peer?, DeviceContactExtendedData?)` (verified at L634 and L1822). The `.flatMap(EnginePeer.init)` wraps the optional `Peer?` to optional `EnginePeer?`. - -### Pattern F — Pass-through (no edit needed) - -These flow transparently through the type change: -- DeviceContactInfoController.swift:897, 1041, 1047 — `subject.peer` access (returns `EnginePeer?` post-migration, consumers use `.id` or `if let peer = subject.peer`) -- DeviceContactInfoController.swift:1041 — `.create(peer: subject.peer, ...)` — both sides EnginePeer? after migration -- DeviceContactInfoController.swift:1149-1163, 1183-1189 — destructured `peer` from `.create` becomes `EnginePeer?`, body accesses `peer.id` and passes to `completion(peer, ...)` (now `EnginePeer?`-accepting) -- ContactsController.swift:312, 785; OpenAddContact.swift:32; ComposeController.swift:220; ShareExtensionContext.swift:532 — `peer: nil` or `.vcard(nil, ...)` constructions, `nil` works for both optional types -- All callback consumer bodies that use `peer?.id` (StoryItemSetContainerViewSendMessage:2141, ChatControllerOpenAttachmentMenu:689, :1856) — `EnginePeer?.id` is `EnginePeer.Id` typealiased to `PeerId`, identical at usage sites - -**Total edits: 17 across 5 files.** AccountContext.swift (4) + DeviceContactInfoController.swift (9) + ChatControllerOpenAttachmentMenu.swift (1 with replace_all) + StoryItemSetContainerViewSendMessage.swift (1) + OpenChatMessage.swift (1) = ~16 Edit calls. - -## Risk register - -| Risk | Mitigation | -|---|---| -| `_asPeer()` source not actually `EnginePeer?` at one of the drop sites | Per-site source typing verified during inventory: 1289 (addContactToExisting callback typed `(EnginePeer?, ...)` at L1409), 1443 (dataSignal returns `(EnginePeer?, ...)`), 1489 (function param `peer: EnginePeer?` at L1481), 2132 (signal callback typed `(EnginePeer?, ...)`), 443 (local `peer: EnginePeer?`). All confirmed. | -| `subject.peer` consumers break | 3 access sites, all pattern `if let peer = subject.peer { ... peer.id ... }`. Body uses `.id` (transparent). | -| Closure capture aliases of destructured `peer` flow into untyped contexts | Inventory found 8 destructure sites; all body uses are `.id` access or pass-through to completion calls (whose signature also migrates). | -| Build cascade through AccountContext consumers | AccountContext is foundational. The enum + computed property changes cascade ALL consumers. Build cost projection: 60-180s. | -| `case let .user(peer)` rebinding shadow at L849 | The outer `peer` (EnginePeer?) is shadowed by the inner `peer` (TelegramUser). Inner body uses `peer.firstName`, `peer.lastName`, `peer.phone` — all TelegramUser fields. No reference to the outer EnginePeer? inside the if-body. Safe. | -| `.flatMap(EnginePeer.init)` simplification leaves wrong type | After migration, destructured `peer: EnginePeer?`. `.flatMap(EnginePeer.init)` would re-wrap to `EnginePeer?` (a no-op). Dropping is safe. | -| Pre-existing `import Postbox` removable from any of the 5 touched files | `import Postbox` should NOT be dropped speculatively — these files use Postbox for unrelated symbols (most consumers retain `Peer` references for non-DeviceContactInfoSubject paths). Defer Postbox-import drops to dedicated cleanup waves. | - -## Wave shape - -**Classification:** wave-91-pattern multi-module enum-payload + callback-signature migration. -**Iteration budget:** 1-3 (target 1; wave 91 took 2; this wave is similar size, slightly more complex). -**Subagent dispatch:** not needed — 17 edits in 5 files is single-implementer scope, but coordinator should review the diff carefully before commit given multi-module footprint. - -## Verification - -### Build - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -`--continueOnError` flag enabled given multi-module scope — surface all errors at once if iter-1 fails. - -### Post-edit residue grep (expect specific patterns) - -```sh -# Construction-site _asPeer drops complete: -grep -nE "subject:\s*\.(vcard|filter|create)\(.*_asPeer\(\)" \ - submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift \ - submodules/TelegramUI/Sources/OpenChatMessage.swift -# Expected: empty. - -# Completion _asPeer drops complete: -grep -nE "completion\(.*_asPeer\(\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# .flatMap(EnginePeer.init) simplifications complete in DeviceContactInfoController: -grep -nE "peer\.flatMap\(EnginePeer\.init\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# Downcast rewrite complete: -grep -nE "peer as\? TelegramUser" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -# Expected: empty. - -# ADD wraps present at the 2 Chat sites: -grep -nE "peerAndContactData\.0\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift -# Expected: 2 lines (683 and 1850, line numbers may shift slightly). -``` - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| `_asPeer()` drops at construction | −5 | DeviceContactInfoController:1289, 1443, 1489 + StoryItemSetContainerViewSendMessage:2132 + OpenChatMessage:443 | -| `_asPeer()` drops at completion calls | −2 | DeviceContactInfoController:1105, 1224 | -| `.flatMap(EnginePeer.init)` simplifications | −3 | DeviceContactInfoController:942, 944, 946 | -| `EnginePeer.init` wraps added (Pattern E) | +2 | ChatControllerOpenAttachmentMenu:683, 1850 | -| Downcast → case-let conversions | +1 | DeviceContactInfoController:849 | -| Type annotations migrated | 4 | AccountContext: 3 enum cases + 1 computed property | - -**Net wrap delta:** **−8** (10 drops minus 2 adds). - -## Out of scope - -- `import Postbox` drops in any of the 5 touched files — they use Postbox for unrelated symbols. Defer to dedicated cleanup waves. -- Migrating the `peerAndContactData` upstream signal type from `(Peer?, DeviceContactExtendedData?)` to `(EnginePeer?, ...)` — would drop the 2 ADD bridges at Chat sites but cascades into multiple closures. Separate wave. -- `addContactToExisting`'s internal completion call sites — already typed `(EnginePeer?, ...)` per L1409, no migration needed in this wave. - -## Memory file update - -After landing, update `project_postbox_refactor_next_wave.md`: -- Add wave 105 outcome line into the recent-waves section. -- Mark `DeviceContactInfoSubject` candidate as drained. -- Note the wave-91-shape success — multi-module enum-payload migrations remain viable when pre-flight inventory clears layers 1-4. diff --git a/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md b/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md deleted file mode 100644 index e37760d6de..0000000000 --- a/docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# Postbox → TelegramEngine wave 106 — speculative `import Postbox` drop sweep (round 2) - -## Context - -Wave 93 (`72de7c4fd5`) ran the first speculative `import Postbox` drop sweep across the consumer modules in `submodules/`. It used a "drop blindly, restore on build feedback" methodology: 12 files dropped, 5 restored after the first build cycle, 7 net imports removed in a single commit. - -Since wave 93, waves 94–105 have removed many further Postbox-typed references from consumer files (storeResourceData/moveResourceData/etc. drains, DeviceContactInfoSubject enum-payload migration, and several Shape-C/D mini-refactors). A second sweep should now find newly-orphaned `import Postbox` lines in files where the last Postbox reference was peeled off by an intervening wave. - -This spec covers wave 106: the round-2 sweep applying the same methodology with an expanded pre-flight regex set incorporating wave 93's escape-case lessons. - -## Goal - -Drop `import Postbox` from any consumer-module Swift file in `submodules/` whose remaining content no longer references a Postbox-only symbol. Single atomic wave commit. No semantic code changes — only `import` and BUILD `deps` lines. - -## Out of scope - -- `submodules/Postbox/` (the module being phased out — never drop its self-references). -- `submodules/TelegramCore/` (different rules; TelegramCore must not `@_exported import Postbox` per wave-1 rule but its internal files retain `import Postbox` as needed). -- `submodules/TelegramApi/` (out of scope for the refactor). -- New typealiases or facade additions — wave 106 is import-cleanup-only. -- Code changes that swap a remaining Postbox-typed reference for an engine equivalent (those are dedicated waves). - -## Methodology - -### Step 1. Inventory candidates - -```sh -grep -rl "^import Postbox" submodules --include="*.swift" \ - | grep -v "^submodules/Postbox/" \ - | grep -v "^submodules/TelegramCore/" \ - | grep -v "^submodules/TelegramApi/" \ - > /tmp/wave106-candidates.txt -``` - -Expected size: roughly 1100–1200 files based on wave-93-era counts adjusted for waves 94–105's drops. - -### Step 2. Pattern-based preemptive restore - -For each candidate file, skip (do NOT drop the import) if it contains any of the following regex patterns. The patterns are split into three tiers; matching ANY one is sufficient to skip. - -**Tier 1 — hard Postbox infrastructure tokens:** -- `\bPostbox\b` -- `\bMediaBox\b` -- `\bMediaResource\b` (the protocol; `TelegramMediaResource` does not match because `\b` boundary) -- `\bMediaResourceData\b` -- `\bMediaResourceId\b` -- `\bPostboxCoding\b` -- `\bPostboxDecoder\b` (rarely escapes — `EnginePostboxDecoder` available, but file may still need import) -- `\bPostboxEncoder\b` (same) -- `\bMemoryBuffer\b` (same) -- `\bTempBoxFile\b` -- `\bValueBoxKey\b` -- `\bPostboxView\b` -- `\bcombinedView\b` - -**Tier 2 — identifier types still defined in Postbox:** -- `\bPeerId\b` -- `\bMessageId\b` -- `\bMediaId\b` -- `\bMessageIndex\b` -- `\bMessageAndThreadId\b` -- `\bPeerNameIndex\b` -- `\bStoryId\b` -- `\bItemCollectionId\b` -- `\bFetchResourceSourceType\b` -- `\bFetchResourceError\b` - -**Tier 3 — bare-name escapes (wave 93 lesson):** -- `\bPeer\b` (the protocol; `EnginePeer` and `TelegramPeer*` and `peer` lowercase do not match) -- `\bMessage\b` (the protocol/struct; `EngineMessage` and `TelegramMessage*` do not match) -- `\bMedia\b` (the protocol; `EngineMedia` and `TelegramMedia*` do not match) - -Skip-list construction: build the regex by joining all three tiers with `|` and run a single `grep -E -l "" $(cat /tmp/wave106-candidates.txt) > /tmp/wave106-skiplist.txt`. The drop-list is `comm -23 <(sort /tmp/wave106-candidates.txt) <(sort /tmp/wave106-skiplist.txt)`. Files in the skip-list keep their `import Postbox`. Over-skipping (false positives from comments or string literals) is safe — it just lowers yield; under-skipping is caught by the build feedback loop in steps 4-5. - -### Step 3. Drop imports - -For each file in `candidates - skip-list`, drop the `import Postbox` line via `Edit` (one Edit per file). All drops happen in a single batch before the first build — wave 93 validated that build feedback handles a large failure batch without manual triage difficulty (`grep "error:" /tmp/build.log | awk -F: '{print $1}' | sort -u` produces the restore list). - -### Step 4. Build with `--continueOnError` - -```sh -source ~/.zshrc 2>/dev/null; \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 \ - --continueOnError 2>&1 | tee /tmp/wave106-build-iter1.log -``` - -Parse the log for `error:` lines. Group by file path; restore `import Postbox` to any file with a missing-symbol error. - -### Step 5. Iterate - -Re-run the build. Repeat restore-and-rebuild until clean. Halt-on-iter-5 (see halt conditions). - -### Step 6. Final clean build (no `--continueOnError`) - -Confirm green build to validate that no inter-module ordering issue was masked. - -### Step 7. BUILD-dep sweep (optional, if time permits) - -For each Bazel package whose Swift sources no longer reference `import Postbox`: - -```sh -# enumerate packages with no remaining Postbox imports -for build in $(find submodules -name BUILD); do - pkg_dir=$(dirname "$build") - if ! grep -rq "^import Postbox" "$pkg_dir" --include="*.swift" 2>/dev/null; then - if grep -q "//submodules/Postbox" "$build"; then - echo "$build" - fi - fi -done -``` - -For each match: drop the `//submodules/Postbox` entry from the package's `BUILD` `deps` list. Re-run the full clean build to confirm. - -### Step 8. Single commit - -All file edits and BUILD changes land in one commit: - -``` -Postbox -> TelegramEngine wave 106 (import drop sweep round 2) - -Speculative drop of `import Postbox` in N files where the last -Postbox-typed symbol reference was peeled off by waves 94-105. -Methodology: pattern-based pre-flight skip + drop + build feedback + -restore loop (wave-93-validated recipe). M files restored after build. -+ K BUILD deps removed. -``` - -## Halt conditions - -1. **Scope drift to TelegramCore/Postbox/TelegramApi.** If build errors surface in any of these three modules, halt immediately and `git reset --hard` the wave. The candidate filter is wrong somewhere. -2. **First-pass failure rate > 50%.** Indicates the pre-flight regex set is missing a major escape pattern. Halt, analyze the failure cluster, expand regex, re-run from step 2. -3. **Iteration count > 5.** Diminishing returns; commit what is green and defer the rest to wave 107. - -## Pre-flight WIP check - -Before any edits: - -```sh -git status --short | grep -v "^??" | grep -v "^ m build-system/bazel-rules/sourcekit-bazel-bsp" -``` - -If output is non-empty, halt — there is unrelated WIP that would get tangled. The known persistent state (untracked `build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/` and the `m` submodule marker) is acceptable and recorded in memory. - -## Expected outcome - -- 5–30 net `import Postbox` drops in `submodules/`. -- 0–3 BUILD `deps` removals. -- 2–3 build iterations. -- Single commit. -- Wall-clock 30–90 min. - -## Risks - -- **Regex misses bare type names** → caught by build feedback at cost of 1 extra cycle. Acceptable. -- **A file holds a Postbox reference only inside a comment** that the regex doesn't distinguish from real code → safe (false positive: file would have been skipped unnecessarily; sweep just leaves the import in). -- **Cross-file dependency where dropping module A's import breaks module B compilation** → caught by build cycle; restore in the failing module. -- **Bazel cache state inconsistency** → unlikely with `--cacheDir ~/telegram-bazel-cache` already in steady state, but if surfaces, full clean build will catch it. - -## Success criteria - -- `git diff --stat` shows only `import Postbox` line removals (and possibly BUILD `deps` line removals). -- Final clean build (no `--continueOnError`) is green. -- No file outside `submodules/` modified. -- No file in `submodules/Postbox/`, `submodules/TelegramCore/`, or `submodules/TelegramApi/` modified. -- Memory file `project_postbox_refactor_next_wave.md` updated to record the wave outcome. diff --git a/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md b/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md deleted file mode 100644 index 544020e7b5..0000000000 --- a/docs/superpowers/specs/2026-04-29-isStrictlyScrolledToPinToEdgeItem-design.md +++ /dev/null @@ -1,68 +0,0 @@ -# `ListViewImpl.isStrictlyScrolledToPinToEdgeItem` — design - -## Goal - -Implement the public method `isStrictlyScrolledToPinToEdgeItem()` on `ListViewImpl` (in `submodules/Display/Source/ListView.swift`). - -The method returns `true` when the list is currently scrolled to the exact resting position of its pin-to-edge target — i.e. the item that lies on the edge of `insets.bottom` is the current pin-to-edge target (the lowest-index item with `pinToEdgeWithInset == true`). - -A stub already exists at `submodules/Display/Source/ListView.swift:2674` (uncommitted, in the working tree) and is the slot to fill in. - -## Definition of "lies on the edge" - -Aligned with the existing pin-to-edge scroll math (`ListView.swift:3115`): - -```swift -offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -``` - -When that offset has been applied, the target item satisfies: - -``` -itemNode.apparentFrame.maxY == (visibleSize.height - insets.bottom) + itemNode.scrollPositioningInsets.bottom -``` - -That equation is the "strictly scrolled" condition. - -## Implementation - -```swift -public func isStrictlyScrolledToPinToEdgeItem() -> Bool { - guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { - return false - } - for itemNode in self.itemNodes { - if itemNode.index == targetIndex { - let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom - return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 - } - } - return false -} -``` - -## Behavior table - -| Situation | Return value | -|---|---| -| No item in `self.items` has `pinToEdgeWithInset == true` | `false` | -| Pin-to-edge target exists but its `itemNode` is not currently materialized | `false` | -| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by ≥ 0.5 pt | `false` | -| Pin-to-edge target's `apparentFrame.maxY` differs from the expected resting `maxY` by < 0.5 pt | `true` | - -## Design choices - -1. **Algorithm shape: target-first, not edge-first.** "Find target, check whether it's at the edge" rather than the user's literal "find item at the edge, check whether it's the target". The two are equivalent in practice (items don't share `maxY` in a stacked list) and target-first is cheaper / clearer. -2. **`apparentFrame`, not the layout frame.** `apparentFrame` already accounts for in-flight animation offsets and matches the property the scroll-target math at line 3115 uses. The result reflects the true visible state, not a possibly-scheduled layout that hasn't taken effect. -3. **Tolerance: 0.5 pt.** Half a logical point — well under any visible misalignment, generous enough for floating-point and pixel-snap noise on 2x/3x displays. No project-wide convention found; 0.5 pt is the chosen default. -4. **Includes `scrollPositioningInsets.bottom`.** Mirrors line 3115 exactly so that an item with non-zero `scrollPositioningInsets.bottom` is reported as "strictly scrolled" at the same position the scroll-to logic would have left it at. - -## Out of scope - -- No new callers are introduced in this spec. The method is added as public API; consumers will be wired up in subsequent work. -- No changes to `ListViewItem.pinToEdgeWithInset`, `calculatePinToEdgeTopInset`, or any pin-to-edge scroll logic. -- No tests — the codebase has no unit tests and this lives in the rendering layer. - -## Verification - -Build the full app target with the standard `Make.py` invocation in `CLAUDE.md` to confirm the addition compiles. diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 4dccd9bd73..3ebe7a80e2 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -2671,6 +2671,22 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur return value } + public func isStrictlyScrolledToPinToEdgeItem() -> Bool { + if self.calculatePinToEdgeTopInset() <= 0.0 { + return false + } + guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { + return false + } + for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } + } + return false + } + private func replayOperations(animated: Bool, animateAlpha: Bool, animateCrossfade: Bool, animateFullTransition: Bool, customAnimationTransition: ControlledTransition?, synchronous: Bool, synchronousLoads: Bool, animateTopItemVerticalOrigin: Bool, operations: [ListViewStateOperation], requestItemInsertionAnimationsIndices: Set, scrollToItem originalScrollToItem: ListViewScrollToItem?, additionalScrollDistance: CGFloat, updateSizeAndInsets: ListViewUpdateSizeAndInsets?, stationaryItemIndex: Int?, updateOpaqueState: Any?, forceInvertOffsetDirection: Bool = false, completion: () -> Void) { var scrollToItem: ListViewScrollToItem? var isExperimentalSnapToScrollToItem = false diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index 1934f56d65..d99dbe3ecd 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -878,6 +878,12 @@ extension ChatControllerImpl { self?.requestLayout(transition: transition) } + var enableSendAnimationV2 = false + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_send_animation_v2"] { + } else { + enableSendAnimationV2 = true + } + self.chatDisplayNode.setupSendActionOnViewUpdate = { [weak self] f, messageCorrelationId in //print("setup layoutActionOnViewTransition") @@ -886,7 +892,11 @@ extension ChatControllerImpl { } self.layoutActionOnViewTransitionAction = f - self.chatDisplayNode.historyNode.pinToTopStableId = nil + if !enableSendAnimationV2 { + self.chatDisplayNode.historyNode.pinToTopStableId = nil + } else if !self.chatDisplayNode.historyNode.isStrictlyScrolledToPinToEdgeItem() { + self.chatDisplayNode.historyNode.pinToTopStableId = nil + } self.chatDisplayNode.historyNode.layoutActionOnViewTransition = ({ [weak self] transition in f() if let strongSelf = self, let validLayout = strongSelf.validLayout { @@ -906,7 +916,7 @@ extension ChatControllerImpl { let shouldUseFastMessageSendAnimation = strongSelf.chatDisplayNode.shouldUseFastMessageSendAnimation - strongSelf.chatDisplayNode.containerLayoutUpdated(validLayout, navigationBarHeight: strongSelf.navigationLayout(layout: validLayout).navigationFrame.maxY, transition: .animated(duration: duration, curve: curve), listViewTransaction: { + strongSelf.chatDisplayNode.containerLayoutUpdated(validLayout, navigationBarHeight: strongSelf.navigationLayout(layout: validLayout).navigationFrame.maxY, transition: .animated(duration: duration, curve: curve), listViewTransaction: { [weak strongSelf] updateSizeAndInsets, _, _, _ in var options = transition.options let _ = options.insert(.Synchronous) @@ -941,6 +951,11 @@ extension ChatControllerImpl { scrollToItem = ListViewScrollToItem(index: insertedIndex, position: .visible, animated: true, curve: .Custom(duration: duration, controlPoints.0, controlPoints.1, controlPoints.2, controlPoints.3), directionHint: .Down) } else if transition.historyView.originalView.laterId == nil { scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Custom(duration: duration, controlPoints.0, controlPoints.1, controlPoints.2, controlPoints.3), directionHint: .Up) + if enableSendAnimationV2, Thread.isMainThread, let strongSelf { + if strongSelf.chatDisplayNode.historyNode.isStrictlyScrolledToPinToEdgeItem() { + scrollToItem = nil + } + } } if let maxInsertedItem = maxInsertedItem { From f34fdef4322f5abf9ecbd69302f9fcd17acb8588 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 29 Apr 2026 23:55:11 +0400 Subject: [PATCH 29/69] Bump version --- versions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/versions.json b/versions.json index 889563a32b..4fce20f3a0 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "app": "12.6.4", + "app": "12.7", "xcode": "26.2", "deploy_xcode": "26.2", "bazel": "8.4.2:45e9388abf21d1107e146ea366ad080eb93cb6a5f3a4a3b048f78de0bc3faffa", From c74220300fb807dd5ceeae38a627fd536215a3df Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 29 Apr 2026 23:29:42 +0200 Subject: [PATCH 30/69] Various fixes --- submodules/ChatListUI/Sources/Node/ChatListItem.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 2e76203112..abc01321fb 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -2589,6 +2589,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if case .user = itemPeer.chatMainPeer { isUser = true } + var isGuestChatAuthor = false + if case let .user(user) = messages.last?.author, let botInfo = user.botInfo, botInfo.flags.contains(.isGuestChat) { + isGuestChatAuthor = true + } var peerText: String? if case .savedMessagesChats = item.chatListLocation { @@ -2606,14 +2610,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let message = messages.last, let forwardInfo = message.forwardInfo, let author = forwardInfo.author { peerText = EnginePeer(author).compactDisplayTitle } - } else if !isUser { + } else if !isUser || isGuestChatAuthor { if case let .channel(peer) = peer, case .broadcast = peer.info { } else if !displayAsMessage { if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported), let authorSignature = forwardInfo.authorSignature { peerText = authorSignature } else { peerText = author.id == account.peerId ? item.presentationData.strings.DialogList_You : EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - authorIsCurrentChat = author.id == peer.id + authorIsCurrentChat = !isGuestChatAuthor && author.id == peer.id } } } From e7662a3de51ceff40b2817fe97aca2c80d600bea Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 12:15:55 +0400 Subject: [PATCH 31/69] Add typing-draft send delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delay outgoing messages while the peer in the same (peerId, threadId) chat is live-typing an incoming message, gated via a new per-account Postbox `.allTypingDrafts` view feeding a synchronous Set membership check inside PendingMessageManager. Postbox: - Add `AllTypingDraftsView` tracking `Set` of active typing drafts; expose via `PostboxViewKey.allTypingDrafts`. - Disambiguate `PostboxViewKey` hash arms for `.typingDrafts` (constant prefix 23) and `.contacts` (16 → 24) to avoid runtime collisions with peerId-only arms and `.combinedReadState`. Hash values are runtime-only, so this is safe. PendingMessageManager: - Add `.waitingForSendGate(groupId:content:)` parking state for single messages and albums whose upload finished but are gated on a typing-draft-clearing event; update `groupId` accessor and `dataForPendingMessageGroup` so partially-parked albums drain on gate open. - Add `.waitingForForwardSendGate` parking state for forwards (no associated values) so drain section (1) routes single non-grouped messages via `commitSendingSingleMessage` and section (3) routes forwards via `sendGroupMessagesContent`, avoiding the double-dispatch that occurred when forwards reused `.waitingForSendGate`. - Subscribe to `.allTypingDrafts`; wire the gate at single-message, album, and forward send paths and at drain. - Cleanup parked forwards on pending-message removal; snapshot Dictionary keys before iterating to avoid mutation during iteration. Specs and plan documents are included under `docs/superpowers/`. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-30-typing-draft-send-delay.md | 703 ++++++++++++++++++ ...26-04-30-typing-draft-send-delay-design.md | 178 +++++ .../Postbox/Sources/AllTypingDraftsView.swift | 49 ++ .../Postbox/Sources/TypingDraftsView.swift | 98 +++ submodules/Postbox/Sources/Views.swift | 25 +- .../Sources/State/PendingMessageManager.swift | 190 ++++- 6 files changed, 1238 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md create mode 100644 docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md create mode 100644 submodules/Postbox/Sources/AllTypingDraftsView.swift create mode 100644 submodules/Postbox/Sources/TypingDraftsView.swift diff --git a/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md b/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md new file mode 100644 index 0000000000..c2e2c5d5f3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md @@ -0,0 +1,703 @@ +# Typing-Draft Send Delay Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Park outgoing messages in `PendingMessageManager` after their content is uploaded, releasing them only when the typing-draft for the matching `(peerId, threadId)` clears. + +**Architecture:** Add a per-account Postbox view (`.allTypingDrafts`) that exposes the live `Set` of currently-active typing drafts. Subscribe once at `PendingMessageManager.init`. Add a parked state (`.waitingForSendGate`) to `PendingMessageState` and a forward parking lot dictionary on the manager. Gate at three sites (single-send, album-send, forward-send); drain on view updates that remove keys. + +**Tech Stack:** Swift, Bazel, Postbox view system, SwiftSignalKit. + +**Spec:** `docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md` + +**Build verification command (used by every task that compiles code):** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +This codebase has **no unit tests** (per `CLAUDE.md`). Verification is "full build green" at each step plus manual exercise after final task. + +--- + +## File Structure + +**Files created:** +- `submodules/Postbox/Sources/AllTypingDraftsView.swift` — new Postbox view tracking the set of active typing-draft keys. + +**Files modified:** +- `submodules/Postbox/Sources/Views.swift` — register the new view key. +- `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` — gate insertion, drain, subscription, parked-state plumbing. + +**No BUILD edits needed:** Postbox `BUILD` uses `glob(["Sources/**/*.swift"])`, so the new file is auto-picked up. + +--- + +## Task 1: Add `MutableAllTypingDraftsView` / `AllTypingDraftsView` + +**Files:** +- Create: `submodules/Postbox/Sources/AllTypingDraftsView.swift` + +This task adds the Postbox view file. It is **not yet wired** into `PostboxViewKey` (Task 2), so this commit alone will not change behavior. + +- [ ] **Step 1: Create `AllTypingDraftsView.swift`** + +```swift +import Foundation + +final class MutableAllTypingDraftsView: MutablePostboxView { + fileprivate var keys: Set + + init(postbox: PostboxImpl) { + self.keys = Set(postbox.currentTypingDrafts.keys) + } + + func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool { + if transaction.updatedTypingDrafts.isEmpty { + return false + } + var updated = false + for (key, update) in transaction.updatedTypingDrafts { + if update.value != nil { + if self.keys.insert(key).inserted { + updated = true + } + } else { + if self.keys.remove(key) != nil { + updated = true + } + } + } + return updated + } + + func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool { + let new = Set(postbox.currentTypingDrafts.keys) + if new == self.keys { + return false + } + self.keys = new + return true + } + + func immutableView() -> PostboxView { + return AllTypingDraftsView(self) + } +} + +public final class AllTypingDraftsView: PostboxView { + public let keys: Set + + init(_ view: MutableAllTypingDraftsView) { + self.keys = view.keys + } +} +``` + +- [ ] **Step 2: Verify the build (file is unwired, so it must compile standalone)** + +Run the build command from the header. Expected: **success**. Common failure modes: `currentTypingDrafts` access scope (already `fileprivate(set)`, accessible from same-module file); `transaction.updatedTypingDrafts` shape (already `[PeerAndThreadId: PostboxImpl.TypingDraftUpdate]` per `PostboxTransaction.swift:59`). + +- [ ] **Step 3: Commit** + +```bash +git add submodules/Postbox/Sources/AllTypingDraftsView.swift +git commit -m "Postbox: add AllTypingDraftsView tracking Set of active typing drafts" +``` + +--- + +## Task 2: Register `.allTypingDrafts` in `PostboxViewKey` + +**Files:** +- Modify: `submodules/Postbox/Sources/Views.swift` + +- [ ] **Step 1: Add the case to the enum** + +In `submodules/Postbox/Sources/Views.swift` line 105, append `.allTypingDrafts` after `.typingDrafts(...)`: + +```swift + case typingDrafts(PeerAndThreadId) + case allTypingDrafts +``` + +- [ ] **Step 2: Add hash arm** + +In the `hash(into:)` switch (the new arm goes alongside the existing `case let .typingDrafts(peerId):` arm at line 232–233): + +```swift + case let .typingDrafts(peerId): + hasher.combine(peerId) + case .allTypingDrafts: + hasher.combine(22) +``` + +The constant `22` is the next free hash-tag (existing tags use 0–21). + +- [ ] **Step 3: Add `==` arm** + +In the `==(lhs:rhs:)` switch (line 551–556 area): + +```swift + case let .typingDrafts(peerId): + if case .typingDrafts(peerId) = rhs { + return true + } else { + return false + } + case .allTypingDrafts: + if case .allTypingDrafts = rhs { + return true + } else { + return false + } +``` + +- [ ] **Step 4: Wire `postboxViewForKey`** + +In the `postboxViewForKey(postbox:key:)` switch (line 684–685 area): + +```swift + case let .typingDrafts(peerId): + return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId) + case .allTypingDrafts: + return MutableAllTypingDraftsView(postbox: postbox) + } +} +``` + +- [ ] **Step 5: Verify the build** + +Run the build command. Expected: **success**. The view is now constructible but no consumer subscribes yet. + +- [ ] **Step 6: Commit** + +```bash +git add submodules/Postbox/Sources/Views.swift +git commit -m "Postbox: wire .allTypingDrafts view key into PostboxViewKey" +``` + +--- + +## Task 3: Add `.waitingForSendGate` state and update related switches + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +This task introduces the new `PendingMessageState` case and extends every switch that needs to know about it. No gate insertion happens yet — the new case is unreachable, so behavior is unchanged. + +- [ ] **Step 1: Add the case to `PendingMessageState`** + +Edit `private enum PendingMessageState` (line 28). After the `.waitingToBeSent` case, add: + +```swift + case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) + case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) +``` + +- [ ] **Step 2: Extend the `groupId` computed property** + +In the same `var groupId: Int64?` switch (line 37–54), add an arm right after the `.waitingToBeSent` arm: + +```swift + case let .waitingToBeSent(groupId, _): + return groupId + case let .waitingForSendGate(groupId, _): + return groupId +``` + +- [ ] **Step 3: Extend `dataForPendingMessageGroup` to accept parked members as ready** + +Find `private func dataForPendingMessageGroup(_ groupId: Int64)` (line 753). After the `.waitingToBeSent` arm, add a parallel arm: + +```swift + case let .waitingToBeSent(contextGroupId, content): + if contextGroupId == groupId { + result.append((context, id, content)) + } + case let .waitingForSendGate(contextGroupId, content): + if contextGroupId == groupId { + result.append((context, id, content)) + } +``` + +This lets a partially-parked album drain correctly once the gate opens. + +- [ ] **Step 4: Exclude parked state from `updatePendingMediaUploads`** + +Find `private func updatePendingMediaUploads()` (line 262). Inside its `switch context.state { ... }`, the existing arms cover `.waitingForUploadToStart` and `.uploading`. Parked state is post-upload and should NOT report progress. The switch already has a `default: break` — `.waitingForSendGate` falls through it automatically. **No edit required**, but verify by re-reading the function after the previous edits compile. + +- [ ] **Step 5: Verify the build** + +Run the build command. Expected: **success**. Swift will reject the build if any `switch context.state { ... }` becomes non-exhaustive — fix any such switches by adding a `case .waitingForSendGate: ...` arm matching the closest existing parallel state. Likely candidates: + +- `private enum PendingMessageState`'s `groupId` switch — handled in Step 2. +- `dataForPendingMessageGroup` switch — handled in Step 3. +- The forward branch in `beginSendingMessages` (line ~614, doesn't switch directly). +- Any other `switch .state` blocks (search with `grep -n "switch.*\.state" submodules/TelegramCore/Sources/State/PendingMessageManager.swift`). + +If the compiler reports a non-exhaustive switch elsewhere, add an arm that mirrors the `.waitingToBeSent` arm in that switch, or `case .waitingForSendGate: break` if the new state is irrelevant to that site. + +- [ ] **Step 6: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: add .waitingForSendGate state and update related switches" +``` + +--- + +## Task 4: Add manager-level subscription and parked-state fields + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +Adds the dictionary, the disposable, the subscription in `init`, the disposal in `deinit`, and a stub `handleLiveTypingDraftsUpdate` that only stores the set (drain is wired in Task 5). + +- [ ] **Step 1: Add stored fields** + +In `public final class PendingMessageManager` (line 205), after the existing `private var pendingMessageIds = Set()` (line 232), add: + +```swift + private var pendingMessageIds = Set() + private var liveTypingDraftKeys: Set = [] + private let allTypingDraftsDisposable = MetaDisposable() + private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:] + private let beginSendingMessagesDisposables = DisposableSet() +``` + +(The first and last lines are already present; the three new lines insert between them.) + +- [ ] **Step 2: Subscribe in `init`** + +In `init(network:postbox:accountPeerId:auxiliaryMethods:stateManager:localInputActivityManager:messageMediaPreuploadManager:revalidationContext:)` (line 243), after the field assignments (line 252), add: + +```swift + self.revalidationContext = revalidationContext + + let queue = self.queue + self.allTypingDraftsDisposable.set( + (postbox.combinedView(keys: [.allTypingDrafts]) + |> deliverOn(queue)).start(next: { [weak self] view in + self?.handleLiveTypingDraftsUpdate(view) + }) + ) + } +``` + +- [ ] **Step 3: Dispose in `deinit`** + +In `deinit` (line 255–260), append: + +```swift + deinit { + self.beginSendingMessagesDisposables.dispose() + for (_, disposable) in self.newTopicDisposables { + disposable.dispose() + } + self.allTypingDraftsDisposable.dispose() + } +``` + +- [ ] **Step 4: Add the handler (no drain yet)** + +Add this new private method anywhere in the class (e.g. just before `private func updatePendingMediaUploads()` at line 262): + +```swift + private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) { + assert(self.queue.isCurrent()) + + let new: Set + if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView { + new = view.keys + } else { + new = [] + } + self.liveTypingDraftKeys = new + } +``` + +This handler is functional — it tracks the live key set correctly. Task 5 augments it to also fire `drainSendGate` for keys that just cleared. + +- [ ] **Step 5: Verify the build** + +Run the build command. Expected: **success**. `CombinedView` is in scope because `Postbox` is already imported at the top of the file. `MetaDisposable` and `Queue` come from `SwiftSignalKit` (already imported). + +- [ ] **Step 6: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: subscribe to .allTypingDrafts and add parking-lot fields" +``` + +--- + +## Task 5: Add gate predicates, drain, and wire the single-message gate + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +Combined task: introduces the predicates, the `drainSendGate` body, augments `handleLiveTypingDraftsUpdate` to call drain, and wires the **first** insertion site (single-message). After this task, single non-grouped messages park correctly when the peer is live-typing and unpark when the draft clears. + +- [ ] **Step 1: Add `shouldGateSend` and `isSendGateOpen` private helpers** + +Anywhere in the `PendingMessageManager` class (group them next to `handleLiveTypingDraftsUpdate` from Task 4): + +```swift + private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool { + if messageId.namespace == Namespaces.Message.ScheduledCloud { + return false + } + if messageId.peerId.namespace == Namespaces.Peer.SecretChat { + return false + } + if messageId.peerId == self.accountPeerId { + return false + } + if threadId == Message.newTopicThreadId { + return false + } + return true + } + + private func isSendGateOpen(for key: PeerAndThreadId) -> Bool { + return !self.liveTypingDraftKeys.contains(key) + } +``` + +- [ ] **Step 2: Augment `handleLiveTypingDraftsUpdate` to call drain** + +Replace the body of `handleLiveTypingDraftsUpdate(_:)` (added in Task 4) with the cleared-key drain logic: + +```swift + private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) { + assert(self.queue.isCurrent()) + + let new: Set + if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView { + new = view.keys + } else { + new = [] + } + let cleared = self.liveTypingDraftKeys.subtracting(new) + self.liveTypingDraftKeys = new + for key in cleared { + self.drainSendGate(key: key) + } + } +``` + +- [ ] **Step 3: Add the `drainSendGate` body** + +Add this new private method next to `handleLiveTypingDraftsUpdate`: + +```swift + private func drainSendGate(key: PeerAndThreadId) { + assert(self.queue.isCurrent()) + + // (1) Single-message drain: snapshot then commit in messageId.id order. + var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = [] + for (id, context) in self.messageContexts { + if id.peerId != key.peerId { + continue + } + if context.threadId != key.threadId { + continue + } + if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil { + singleDrains.append((context, id, content)) + } + } + singleDrains.sort(by: { $0.messageId.id < $1.messageId.id }) + for entry in singleDrains { + self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content) + } + + // (2) Grouped-album drain: collect distinct groupIds whose members match the key, + // iterate ascending by min messageId.id, fire commitSendingMessageGroup. + var groupKeys: [(groupId: Int64, minMessageId: Int32)] = [] + var seenGroupIds = Set() + for (id, context) in self.messageContexts { + if id.peerId != key.peerId { + continue + } + if context.threadId != key.threadId { + continue + } + if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId { + if !seenGroupIds.contains(groupId) { + seenGroupIds.insert(groupId) + groupKeys.append((groupId, id.id)) + } else { + if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId { + groupKeys[index].minMessageId = id.id + } + } + } + } + groupKeys.sort(by: { $0.minMessageId < $1.minMessageId }) + for (groupId, _) in groupKeys { + if let data = self.dataForPendingMessageGroup(groupId) { + self.commitSendingMessageGroup(groupId: groupId, messages: data) + } + } + + // (3) Forward drain: pop parked groups for this key in FIFO order; fire each. + if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) { + for messages in parkedGroups { + for (context, _, _) in messages { + context.state = .sending(groupId: nil) + } + let sendMessage: Signal = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in + let (_, message, forwardInfo) = data + return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) + }) + |> map { _ -> PendingMessageResult in + return .progress(1.0) + } + messages[0].0.sendDisposable.set((sendMessage + |> deliverOn(self.queue)).start()) + } + } + + self.updateWaitingUploads(peerId: key.peerId) + self.updatePendingMediaUploads() + } +``` + +The forward dispatch block mirrors the existing forward-fire code at lines 719–731 of `PendingMessageManager.swift`. Keep them in sync if either changes. + +- [ ] **Step 4: Wire the single-message gate at `beginSendingMessage`** + +Replace `private func beginSendingMessage(messageContext:messageId:groupId:content:)` (line 738) with: + +```swift + private func beginSendingMessage(messageContext: PendingMessageContext, messageId: MessageId, groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) { + if let groupId = groupId { + messageContext.state = .waitingToBeSent(groupId: groupId, content: content) + } else { + let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId) + if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) { + messageContext.state = .waitingForSendGate(groupId: nil, content: content) + } else { + self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) + } + } + self.updatePendingMediaUploads() + } +``` + +- [ ] **Step 5: Verify the build** + +Run the build command. Expected: **success**. + +- [ ] **Step 6: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: wire typing-draft gate at single-message send + drain" +``` + +--- + +## Task 6: Wire the album-send gate at `commitSendingMessageGroup` + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +- [ ] **Step 1: Replace `commitSendingMessageGroup`** + +Replace `private func commitSendingMessageGroup(groupId:messages:)` (line 794) with: + +```swift + private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) { + let firstMessageId = messages[0].messageId + let firstThreadId = messages[0].messageContext.threadId + let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId) + if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) { + for entry in messages { + entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content) + } + return + } + + for (context, _, _) in messages { + context.state = .sending(groupId: groupId) + } + let sendMessage: Signal = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { ($0.1, $0.2) }) + |> map { next -> PendingMessageResult in + return .progress(1.0) + } + messages[0].0.sendDisposable.set((sendMessage + |> deliverOn(self.queue)).start()) + } +``` + +Album members share `(peerId, threadId)` by construction (the album fires once every member is post-upload in the same `groupId`). + +- [ ] **Step 2: Verify the build** + +Run the build command. Expected: **success**. + +- [ ] **Step 3: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: wire typing-draft gate at album send" +``` + +--- + +## Task 7: Wire the forward-send gate inside `beginSendingMessages` + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +- [ ] **Step 1: Replace the forward-dispatch loop** + +The existing forward-dispatch loop is at lines 714–733 inside `beginSendingMessages`. Replace exactly the body of `for messages in countedMessageGroups { ... }` with the gate-aware version: + +```swift + for messages in countedMessageGroups { + if messages.isEmpty { + continue + } + + let firstMessage = messages[0].1 + let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId) + if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) { + for (context, _, forwardInfo) in messages { + context.state = .waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) + } + if strongSelf.forwardSendGateGroups[key] == nil { + strongSelf.forwardSendGateGroups[key] = [] + } + strongSelf.forwardSendGateGroups[key]!.append(messages) + continue + } + + for (context, _, _) in messages { + context.state = .sending(groupId: nil) + } + + let sendMessage: Signal = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in + let (_, message, forwardInfo) = data + return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) + }) + |> map { next -> PendingMessageResult in + return .progress(1.0) + } + messages[0].0.sendDisposable.set((sendMessage + |> deliverOn(strongSelf.queue)).start()) + } +``` + +The non-gated branch is the existing code, copied verbatim. The gated branch parks every context in `.waitingForSendGate` and appends the whole tuple-array onto `forwardSendGateGroups[key]`. The drain in `Task 5` reads from this dict. + +- [ ] **Step 2: Verify the build** + +Run the build command. Expected: **success**. + +- [ ] **Step 3: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: wire typing-draft gate at forward send" +``` + +--- + +## Task 8: Clean up parked forwards in `updatePendingMessageIds` removal loop + +**Files:** +- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` + +When a message is removed from `pendingMessageIds` (e.g. user discarded it, or it was force-deleted), the existing loop disposes context-level state. Parked single/album state lives on the `PendingMessageContext` and gets reset when `state = .none`. Parked forwards live in `forwardSendGateGroups`, which the existing loop does not touch — patch that here. + +- [ ] **Step 1: Add cleanup for `forwardSendGateGroups` in `updatePendingMessageIds`** + +In `updatePendingMessageIds(_:)` (line 284), after the `for id in removedMessageIds { ... }` loop (closes around line 323), add a forward-cleanup pass before `if !addedMessageIds.isEmpty { ... }`: + +```swift + if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty { + for (key, parkedGroups) in self.forwardSendGateGroups { + var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = [] + for group in parkedGroups { + let filtered = group.filter { entry in + return !removedMessageIds.contains(entry.1.id) + } + if !filtered.isEmpty { + rebuilt.append(filtered) + } + } + if rebuilt.isEmpty { + self.forwardSendGateGroups.removeValue(forKey: key) + } else { + self.forwardSendGateGroups[key] = rebuilt + } + } + } +``` + +- [ ] **Step 2: Verify the build** + +Run the build command. Expected: **success**. + +- [ ] **Step 3: Commit** + +```bash +git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift +git commit -m "PendingMessageManager: cleanup parked forwards on pending-message removal" +``` + +--- + +## Task 9: Manual verification + +**Files:** None (verification-only). + +This codebase has no unit tests, so the final acceptance gate is a manual exercise on a debug simulator build. Skip if you don't have a working two-device test setup; in that case, re-confirm only the build is green. + +- [ ] **Step 1: Confirm full build is still green** + +Run the build command from the header. Expected: **success**. + +- [ ] **Step 2: 1:1 chat, text-send delay** + +Setup: log in to two real Telegram accounts on two devices/simulators (account A and account B). Open the A↔B 1:1 chat on both. + +- On B, begin live-typing a draft (Telegram clients with the live-typing-drafts feature emit the typing-draft updates; if B doesn't expose live drafts, simulate by triggering whatever flow populates `transaction.combineTypingDrafts(...)` in your test environment). +- On A, immediately type and send a text message. Confirm: the message appears with a "sending" indicator and does **not** complete until B's draft clears or commits. Once B's draft is cleared, A's message sends within ~1 second. + +- [ ] **Step 3: Album / grouped-media delay** + +Repeat Step 2 with an album of two photos sent from A while B is live-typing. Expected: all album members upload (you can see progress finish), then all sit parked at "sending" until the gate opens. + +- [ ] **Step 4: Forward delay** + +Repeat with a forwarded message (forward a message from a third chat into A↔B while B is live-typing). Expected: forward parks until the gate opens. + +- [ ] **Step 5: Negative — Saved Messages skip** + +In Saved Messages (chat with self), send any message. Confirm: never delays, regardless of typing-draft state. There should be no typing draft for the self peer in the first place — this is just an existence check that the skip-rule does its job. + +- [ ] **Step 6: Negative — secret chat skip** + +In a secret chat, send a message. Confirm: never delays. Server-side, secret chats don't emit typing-draft updates — this verifies the explicit skip-check. + +- [ ] **Step 7: Negative — scheduled message skip (defensive)** + +The `Namespaces.Message.ScheduledCloud` skip in `shouldGateSend` is defensive — in practice, scheduled messages are stored in the scheduled queue and only enter `PendingMessageManager` at delivery time, by which point they've been re-created with cloud namespace. Verifying the defensive branch directly is awkward and not strictly required. If you have an instrumentation path that forces a scheduled-namespace message through `beginSendingMessages`, confirm it doesn't park. + +- [ ] **Step 8: Multi-thread — gate is per-thread** + +In a forum/topic group, on B begin live-typing in topic 1 only. On A, send a message to topic 2 of the same group. Expected: A's message to topic 2 is **not** delayed. + +--- + +## Self-review notes (already applied inline) + +- **Spec coverage:** every section of `2026-04-30-typing-draft-send-delay-design.md` maps to a task — Postbox view (Tasks 1–2), state machine extension (Task 3), subscription (Task 4), predicates + drain + single-send (Task 5), album (Task 6), forwards (Task 7), removal cleanup (Task 8), manual verification (Task 9). +- **Type names verified against actual code:** `PendingMessageState`, `PendingMessageContext`, `PendingMessageUploadedContentAndReuploadInfo`, `ForwardSourceInfoAttribute`, `PendingMessageResult`, `Signal`, `MetaDisposable`, `Queue`, `CombinedView`, `Namespaces.Message.ScheduledCloud`, `Namespaces.Peer.SecretChat`, `Message.newTopicThreadId`, `PeerAndThreadId`, `combineTypingDrafts`, `currentTypingDrafts`, `transaction.updatedTypingDrafts`, `update.value`. All names match the source. +- **`postponeSending` (paid-message) interaction:** untouched. Composes via state-machine ordering (paid postpone gates upload-start; this gate sits post-upload). +- **No unused-private-function warnings:** every helper is introduced together with its first caller (predicates + drain + first call site combined in Task 5). diff --git a/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md b/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md new file mode 100644 index 0000000000..09952f55d1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md @@ -0,0 +1,178 @@ +# Typing-Draft Send Delay — Design + +**Date:** 2026-04-30 +**Component:** `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` (+ minimal Postbox additions) + +## Goal + +Delay outgoing messages while the peer in the same `(peerId, threadId)` is "live-typing" an incoming message (i.e. `Postbox.combinedView(keys: [.typingDrafts(...)])` reports a non-nil draft for that key). Messages park after their content is fully uploaded, then drain in `messageId.id` order once the typing-draft for that key clears. + +## Behavior summary + +- **Scope.** All "deliver-now" outgoing message types: regular text/media single sends, grouped media albums, and forwards. Excluded: scheduled messages, secret-chat messages, and Saved Messages (account-self peer). +- **Pipeline.** Uploads run in parallel as they do today. The gate sits between "upload complete" and the actual MTProto send call. +- **Release.** As soon as the typing-draft for the message's `(peerId, threadId)` clears (the view's set no longer contains that key) — no extra grace delay, no upper-bound timeout. +- **Keying.** Strictly per-thread. `threadId == nil` is the normal value for non-threaded chats and gates with `PeerAndThreadId(peerId: ..., threadId: nil)`. The `Message.newTopicThreadId` sentinel does not gate (already handled by `.waitingForNewTopic`). +- **Always-on.** No preference toggle. +- **Composes with paid-message postpone.** Paid postpone gates upload-start; typing-draft gate gates post-upload send. Both must be clear before send. + +## Architecture + +All logic lives in `PendingMessageManager`. Postbox gains one new public view; no other Postbox API changes. + +### Postbox additions + +1. New file `submodules/Postbox/Sources/AllTypingDraftsView.swift`: + - `MutableAllTypingDraftsView: MutablePostboxView` + - `init(postbox:)` seeds `keys` from `postbox.currentTypingDrafts.keys`. + - `replay(postbox:transaction:)` diffs against `transaction.updatedTypingDrafts`: insert key when `update.value != nil`, remove when nil. Returns `true` if the set changed. + - `refreshDueToExternalTransaction(postbox:)` reloads from `postbox.currentTypingDrafts.keys` and returns `true`. + - `immutableView()` returns an `AllTypingDraftsView`. + - `public final class AllTypingDraftsView: PostboxView` exposes `public let keys: Set`. +2. `submodules/Postbox/Sources/Views.swift`: + - Add `case allTypingDrafts` to `PostboxViewKey` (no associated payload). + - Wire constant `Hashable` combine and `==` matching for the new case. + - Add the `case .allTypingDrafts` arm to `postboxViewForKey` returning `MutableAllTypingDraftsView(postbox:)`. +3. `PostboxImpl.currentTypingDrafts` is `fileprivate(set)`, accessible from view files in the same module. No new accessor needed. + +### PendingMessageManager additions + +New `PendingMessageState` case: + +```swift +case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) +``` + +Added to `PendingMessageState.groupId`'s switch. Excluded from `updatePendingMediaUploads`'s upload-progress aggregation. + +New stored state on the manager: + +```swift +private var liveTypingDraftKeys: Set = [] +private let allTypingDraftsDisposable = MetaDisposable() +private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:] +``` + +In `init`, subscribe once: + +```swift +self.allTypingDraftsDisposable.set( + (postbox.combinedView(keys: [.allTypingDrafts]) + |> deliverOn(self.queue)).start(next: { [weak self] view in + self?.handleLiveTypingDraftsUpdate(view) + }) +) +``` + +Dispose in `deinit`. + +## Gate predicate + +```swift +private func isSendGateOpen(for key: PeerAndThreadId) -> Bool { + return !self.liveTypingDraftKeys.contains(key) +} + +private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool { + if messageId.namespace == Namespaces.Message.ScheduledCloud { return false } + if messageId.peerId.namespace == Namespaces.Peer.SecretChat { return false } + if messageId.peerId == self.accountPeerId { return false } + if threadId == Message.newTopicThreadId { return false } + return true +} +``` + +A pending context is gate-applicable if `shouldGateSend(...)` returns true. The gate is open if `isSendGateOpen(...)` returns true. Sites delay the send only when `shouldGateSend && !isSendGateOpen`. + +## Gate insertion points + +### (a) Single-message — `beginSendingMessage(messageContext:messageId:groupId:content:)` + +Today: `groupId == nil → commitSendingSingleMessage`; otherwise `state = .waitingToBeSent(groupId: ..., content: ...)`. + +New: when `groupId == nil`, additionally check the gate: + +```swift +let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId) +if shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !isSendGateOpen(for: key) { + messageContext.state = .waitingForSendGate(groupId: nil, content: content) +} else { + self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) +} +``` + +The grouped path (`groupId != nil`) is unchanged here; gating for albums happens in (b). + +### (b) Grouped-album — `commitSendingMessageGroup(groupId:messages:)` + +Today: flips every group context to `.sending(groupId:)`, fires `sendGroupMessagesContent`. + +New: derive a representative key from the first message's `(peerId, threadId)`. (Group members share both by construction.) If `shouldGateSend && !isSendGateOpen`, flip every group context to `.waitingForSendGate(groupId: groupId, content: )` and return. Otherwise unchanged. + +`dataForPendingMessageGroup(_ groupId:)` is updated to recognize `.waitingForSendGate(groupId: contextGroupId, ...)` the same way it recognizes `.waitingToBeSent` — i.e. a group becomes "ready" when every member is in `.waitingToBeSent` OR `.waitingForSendGate`. This prevents partial-park deadlocks. + +### (c) Forwards — inside `beginSendingMessages`, lines 714–733 + +Today: builds `countedMessageGroups` and immediately fires `sendGroupMessagesContent` per group. + +The pre-existing `messagesToForward` bucketing is by `PeerIdAndNamespace` only — not by `threadId`. The downstream `sendGroupMessagesContent` network call requires thread homogeneity (a forward dispatch targets a single destination thread), so in practice every group already shares `threadId`. The gate uses this assumption: derive the key from `messages[0].1.threadId` of each `countedMessageGroup`. If a future caller violates the assumption, the existing dispatch path is already broken. + +New: per group, derive `key = PeerAndThreadId(peerId: messages[0].1.id.peerId, threadId: messages[0].1.threadId)`. If `shouldGateSend && !isSendGateOpen`, flip every context in the group to `.waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))` and append the entire `[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]` group to `forwardSendGateGroups[key]`. Otherwise fire as today. + +Forward groups within a key drain in FIFO order. + +## Drain logic + +`drainSendGate(key: PeerAndThreadId)` runs on `self.queue`. Idempotent. + +1. **Single-message drain.** Snapshot `messageContexts` filtering on `state == .waitingForSendGate(groupId: nil, ...)` AND `PeerAndThreadId(peerId: contextId.peerId, threadId: context.threadId) == key`. Sort by `messageId.id` ascending. For each, extract the parked `content`, call `commitSendingSingleMessage(messageContext:messageId:content:)`. +2. **Grouped-album drain.** Collect distinct `groupId`s among `.waitingForSendGate(groupId: , ...)` contexts whose key matches. Iterate in ascending min-`messageId.id`-in-group order. For each, call `dataForPendingMessageGroup(groupId)` (which now sees the parked members as ready) and pass the result to `commitSendingMessageGroup(groupId:messages:)`. +3. **Forward drain.** Pop `forwardSendGateGroups.removeValue(forKey: key)`. For each parked group (FIFO): flip every context to `.sending(groupId: nil)`, build the `[(MessageId, PendingMessageUploadedContentAndReuploadInfo)]` array, fire `sendGroupMessagesContent` exactly mirroring the existing forward-fire code (lines 719–731). +4. After (1)–(3), call `updateWaitingUploads(peerId: key.peerId)` and `updatePendingMediaUploads()` once. + +`handleLiveTypingDraftsUpdate(_ view: CombinedView)`: + +```swift +let view = (view.views[.allTypingDrafts] as? AllTypingDraftsView) +let new = view?.keys ?? [] +let cleared = self.liveTypingDraftKeys.subtracting(new) +self.liveTypingDraftKeys = new +for key in cleared { + self.drainSendGate(key: key) +} +``` + +Single-emission semantics: a `(false → true)` transition (key newly populated) parks future arrivals only; in-flight `.sending` continues. A `(true → false)` transition fires drain. + +## Side effects on existing helpers + +- `PendingMessageState.groupId` switch (line 37): add `.waitingForSendGate` case returning the case's `groupId` (forward parking uses `groupId == nil`; grouped-album parking uses the real groupId). +- `updatePendingMediaUploads()` (line 262): `.waitingForSendGate` is **not** treated as uploading. Excluded from the switch (or explicitly returns `default` early). +- `dataForPendingMessageGroup(_ groupId:)` (line 753): add `.waitingForSendGate(contextGroupId, content)` arm — if `contextGroupId == groupId`, append `(context, id, content)` to result, same as the existing `.waitingToBeSent` arm. +- `updatePendingMessageIds(_:)` (line 284): in the existing `for id in removedMessageIds` loop, additionally drop `forwardSendGateGroups[*]` entries whose contained context matches `id`. (Single/album parking is auto-cleaned because parked state lives on the context, which gets `state = .none`.) Implementation: walk the dict, filter out the removed context from each parked group, drop any group that empties out, drop any key whose value-array empties out. + +## Edge cases + +- **First-emit race.** `liveTypingDraftKeys` initializes to `[]`. If a send is attempted before the first view emit and a draft is actually active, that single message slips through. Tolerated. +- **Saved Messages / secret chats / scheduled / new-topic sentinel.** All explicit skip-cases in `shouldGateSend`. +- **Self-typing on another device.** A draft we authored on another device is treated like any other — our outgoing send to that chat parks until it clears. This is consistent with the design intent (drafts visibly commit before subsequent sends arrive). No author filter. +- **Removed-while-parked.** Handled by `updatePendingMessageIds(_:)` extension above. +- **Re-entrancy.** Drain helpers snapshot work-lists before iterating, so mid-iteration mutations to `messageContexts` (e.g. a fired send completes synchronously) don't corrupt the loop. +- **Paid postpone composition.** Paid postpone gates upload-start; once paid commit fires, upload runs; once upload completes, the typing-draft gate parks at `.waitingForSendGate`; once the draft clears, send fires. Stacked sequentially without interaction. +- **Subscription teardown.** `allTypingDraftsDisposable.dispose()` in `deinit`. + +## Testing + +This codebase has no unit tests. Verification is via full build + manual exercise: + +- Build: `python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError` (prefixed with `source ~/.zshrc 2>/dev/null;`). +- Manual: in a 1:1 chat with another device, induce a live-typing draft on the peer side and confirm an outgoing text send parks (chat shows "sending" status held until draft clears or commits). Repeat for: media single send, grouped media album, forward. +- Negative manual: scheduled message — confirm not gated. Saved Messages — confirm not gated. Secret chat — confirm not gated. + +## Out of scope + +- Per-chat opt-in toggle. +- Upper-bound timeout / fallback send. +- Grace-delay after draft clears. +- UI affordance ("waiting for X to finish typing…"). +- Filtering self-authored drafts. diff --git a/submodules/Postbox/Sources/AllTypingDraftsView.swift b/submodules/Postbox/Sources/AllTypingDraftsView.swift new file mode 100644 index 0000000000..5290154e63 --- /dev/null +++ b/submodules/Postbox/Sources/AllTypingDraftsView.swift @@ -0,0 +1,49 @@ +import Foundation + +final class MutableAllTypingDraftsView: MutablePostboxView { + fileprivate var keys: Set + + init(postbox: PostboxImpl) { + self.keys = Set(postbox.currentTypingDrafts.keys) + } + + func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool { + if transaction.updatedTypingDrafts.isEmpty { + return false + } + var updated = false + for (key, update) in transaction.updatedTypingDrafts { + if update.value != nil { + if self.keys.insert(key).inserted { + updated = true + } + } else { + if self.keys.remove(key) != nil { + updated = true + } + } + } + return updated + } + + func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool { + let new = Set(postbox.currentTypingDrafts.keys) + if new == self.keys { + return false + } + self.keys = new + return true + } + + func immutableView() -> PostboxView { + return AllTypingDraftsView(self) + } +} + +public final class AllTypingDraftsView: PostboxView { + public let keys: Set + + init(_ view: MutableAllTypingDraftsView) { + self.keys = view.keys + } +} diff --git a/submodules/Postbox/Sources/TypingDraftsView.swift b/submodules/Postbox/Sources/TypingDraftsView.swift new file mode 100644 index 0000000000..cb8a1bf361 --- /dev/null +++ b/submodules/Postbox/Sources/TypingDraftsView.swift @@ -0,0 +1,98 @@ +import Foundation + +final class MutableTypingDraftsView: MutablePostboxView { + fileprivate let peerAndThreadId: PeerAndThreadId + fileprivate var typingDraft: Message? + + init(postbox: PostboxImpl, peerAndThreadId: PeerAndThreadId) { + self.peerAndThreadId = peerAndThreadId + + self.reload(postbox: postbox) + } + + private func reload(postbox: PostboxImpl) { + if let typingDraft = postbox.currentTypingDrafts[self.peerAndThreadId] { + self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft) + } else { + self.typingDraft = nil + } + } + + func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool { + var updated = false + + if let typingDraftUpdate = transaction.updatedTypingDrafts[self.peerAndThreadId] { + if let typingDraft = typingDraftUpdate.value { + self.typingDraft = self.renderTypingDraft(postbox: postbox, typingDraft: typingDraft) + } else { + self.typingDraft = nil + } + updated = true + } + + return updated + } + + private func renderTypingDraft(postbox: PostboxImpl, typingDraft: PostboxImpl.TypingDraft) -> Message? { + guard let peer = postbox.peerTable.get(self.peerAndThreadId.peerId), let author = postbox.peerTable.get(typingDraft.authorId) else { + return nil + } + + var peers = SimpleDictionary() + peers[peer.id] = peer + peers[author.id] = author + + var associatedThreadInfo: Message.AssociatedThreadInfo? + if let threadId = typingDraft.threadId, let data = postbox.messageHistoryThreadIndexTable.get(peerId: self.peerAndThreadId.peerId, threadId: threadId) { + associatedThreadInfo = postbox.seedConfiguration.decodeMessageThreadInfo(data.data) + } + + return Message( + stableId: typingDraft.stableId, + stableVersion: typingDraft.stableVersion, + id: MessageId( + peerId: self.peerAndThreadId.peerId, + namespace: 1, + id: Int32.max - 50000), + globallyUniqueId: nil, + groupingKey: nil, + groupInfo: nil, + threadId: typingDraft.threadId, + timestamp: typingDraft.timestamp, + flags: [.Incoming], + tags: [], + globalTags: [], + localTags: [], + customTags: [], + forwardInfo: nil, + author: author, + text: typingDraft.text, + attributes: typingDraft.attributes, + media: [], + peers: peers, + associatedMessages: SimpleDictionary(), + associatedMessageIds: [], + associatedMedia: [:], + associatedThreadInfo: associatedThreadInfo, + associatedStories: [:] + ) + } + + func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool { + self.reload(postbox: postbox) + + return true + } + + func immutableView() -> PostboxView { + return TypingDraftsView(self) + } +} + +public final class TypingDraftsView: PostboxView { + public let typingDraft: Message? + + init(_ view: MutableTypingDraftsView) { + self.typingDraft = view.typingDraft + } +} diff --git a/submodules/Postbox/Sources/Views.swift b/submodules/Postbox/Sources/Views.swift index ab21dcf429..6d5453d416 100644 --- a/submodules/Postbox/Sources/Views.swift +++ b/submodules/Postbox/Sources/Views.swift @@ -102,6 +102,8 @@ public enum PostboxViewKey: Hashable { case savedMessagesStats(peerId: PeerId) case chatInterfaceState(peerId: PeerId) case historyView(HistoryView) + case typingDrafts(PeerAndThreadId) + case allTypingDrafts public func hash(into hasher: inout Hasher) { switch self { @@ -187,7 +189,7 @@ public enum PostboxViewKey: Hashable { case let .topChatMessage(peerIds): hasher.combine(peerIds) case .contacts: - hasher.combine(16) + hasher.combine(24) case let .deletedMessages(peerId): hasher.combine(peerId) case let .notice(key): @@ -228,6 +230,11 @@ public enum PostboxViewKey: Hashable { hasher.combine(20) hasher.combine(historyView.peerId) hasher.combine(historyView.threadId) + case let .typingDrafts(peerId): + hasher.combine(23) + hasher.combine(peerId) + case .allTypingDrafts: + hasher.combine(22) } } @@ -545,6 +552,18 @@ public enum PostboxViewKey: Hashable { } else { return false } + case let .typingDrafts(peerId): + if case .typingDrafts(peerId) = rhs { + return true + } else { + return false + } + case .allTypingDrafts: + if case .allTypingDrafts = rhs { + return true + } else { + return false + } } } } @@ -672,5 +691,9 @@ func postboxViewForKey(postbox: PostboxImpl, key: PostboxViewKey) -> MutablePost topTaggedMessages: [:], additionalDatas: [] ) + case let .typingDrafts(peerId): + return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId) + case .allTypingDrafts: + return MutableAllTypingDraftsView(postbox: postbox) } } diff --git a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift index 94767eeedf..951b02d2c4 100644 --- a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift +++ b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift @@ -31,6 +31,8 @@ private enum PendingMessageState { case waitingForUploadToStart(groupId: Int64?, upload: Signal) case uploading(groupId: Int64?) case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) + case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) + case waitingForForwardSendGate case sending(groupId: Int64?) case waitingForNewTopic(message: Message) @@ -46,6 +48,10 @@ private enum PendingMessageState { return groupId case let .waitingToBeSent(groupId, _): return groupId + case let .waitingForSendGate(groupId, _): + return groupId + case .waitingForForwardSendGate: + return nil case let .sending(groupId): return groupId case let .waitingForNewTopic(message): @@ -230,6 +236,9 @@ public final class PendingMessageManager { private var messageContexts: [MessageId: PendingMessageContext] = [:] private var pendingMessageIds = Set() + private var liveTypingDraftKeys: Set = [] + private let allTypingDraftsDisposable = MetaDisposable() + private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:] private let beginSendingMessagesDisposables = DisposableSet() private var newTopicDisposables: [PeerId: Disposable] = [:] @@ -250,6 +259,14 @@ public final class PendingMessageManager { self.localInputActivityManager = localInputActivityManager self.messageMediaPreuploadManager = messageMediaPreuploadManager self.revalidationContext = revalidationContext + + let queue = self.queue + self.allTypingDraftsDisposable.set( + (postbox.combinedView(keys: [.allTypingDrafts]) + |> deliverOn(queue)).start(next: { [weak self] view in + self?.handleLiveTypingDraftsUpdate(view) + }) + ) } deinit { @@ -257,8 +274,117 @@ public final class PendingMessageManager { for (_, disposable) in self.newTopicDisposables { disposable.dispose() } + self.allTypingDraftsDisposable.dispose() } + private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) { + assert(self.queue.isCurrent()) + + let new: Set + if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView { + new = view.keys + } else { + new = [] + } + let cleared = self.liveTypingDraftKeys.subtracting(new) + self.liveTypingDraftKeys = new + for key in cleared { + self.drainSendGate(key: key) + } + } + + private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool { + if messageId.namespace == Namespaces.Message.ScheduledCloud { + return false + } + if messageId.peerId.namespace == Namespaces.Peer.SecretChat { + return false + } + if messageId.peerId == self.accountPeerId { + return false + } + if threadId == Message.newTopicThreadId { + return false + } + return true + } + + private func isSendGateOpen(for key: PeerAndThreadId) -> Bool { + return !self.liveTypingDraftKeys.contains(key) + } + + private func drainSendGate(key: PeerAndThreadId) { + assert(self.queue.isCurrent()) + + // (1) Single-message drain: snapshot then commit in messageId.id order. + var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = [] + for (id, context) in self.messageContexts { + if id.peerId != key.peerId { + continue + } + if context.threadId != key.threadId { + continue + } + if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil { + singleDrains.append((context, id, content)) + } + } + singleDrains.sort(by: { $0.messageId.id < $1.messageId.id }) + for entry in singleDrains { + self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content) + } + + // (2) Grouped-album drain: collect distinct groupIds whose members match the key, + // iterate ascending by min messageId.id, fire commitSendingMessageGroup. + var groupKeys: [(groupId: Int64, minMessageId: Int32)] = [] + var seenGroupIds = Set() + for (id, context) in self.messageContexts { + if id.peerId != key.peerId { + continue + } + if context.threadId != key.threadId { + continue + } + if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId { + if !seenGroupIds.contains(groupId) { + seenGroupIds.insert(groupId) + groupKeys.append((groupId, id.id)) + } else { + if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId { + groupKeys[index].minMessageId = id.id + } + } + } + } + groupKeys.sort(by: { $0.minMessageId < $1.minMessageId }) + for (groupId, _) in groupKeys { + if let data = self.dataForPendingMessageGroup(groupId) { + self.commitSendingMessageGroup(groupId: groupId, messages: data) + } + } + + // (3) Forward drain: pop parked groups for this key in FIFO order; fire each. + if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) { + for messages in parkedGroups { + for (context, _, _) in messages { + context.state = .sending(groupId: nil) + } + let sendMessage: Signal = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in + let (_, message, forwardInfo) = data + return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) + }) + |> map { _ -> PendingMessageResult in + return .progress(1.0) + } + messages[0].0.sendDisposable.set((sendMessage + |> deliverOn(self.queue)).start()) + } + } + + self.updateWaitingUploads(peerId: key.peerId) + self.updatePendingMediaUploads() + } + private func updatePendingMediaUploads() { assert(self.queue.isCurrent()) @@ -321,7 +447,29 @@ public final class PendingMessageManager { } } } - + + if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty { + for key in Array(self.forwardSendGateGroups.keys) { + guard let parkedGroups = self.forwardSendGateGroups[key] else { + continue + } + var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = [] + for group in parkedGroups { + let filtered = group.filter { entry in + return !removedMessageIds.contains(entry.1.id) + } + if !filtered.isEmpty { + rebuilt.append(filtered) + } + } + if rebuilt.isEmpty { + self.forwardSendGateGroups.removeValue(forKey: key) + } else { + self.forwardSendGateGroups[key] = rebuilt + } + } + } + if !addedMessageIds.isEmpty { Logger.shared.log("PendingMessageManager", "added messages: \(addedMessageIds)") self.beginSendingMessages(Array(addedMessageIds).sorted()) @@ -715,11 +863,24 @@ public final class PendingMessageManager { if messages.isEmpty { continue } - + + let firstMessage = messages[0].1 + let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId) + if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) { + for (context, _, _) in messages { + context.state = .waitingForForwardSendGate + } + if strongSelf.forwardSendGateGroups[key] == nil { + strongSelf.forwardSendGateGroups[key] = [] + } + strongSelf.forwardSendGateGroups[key]!.append(messages) + continue + } + for (context, _, _) in messages { context.state = .sending(groupId: nil) } - + let sendMessage: Signal = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in let (_, message, forwardInfo) = data return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) @@ -739,7 +900,12 @@ public final class PendingMessageManager { if let groupId = groupId { messageContext.state = .waitingToBeSent(groupId: groupId, content: content) } else { - self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) + let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId) + if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) { + messageContext.state = .waitingForSendGate(groupId: nil, content: content) + } else { + self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) + } } self.updatePendingMediaUploads() } @@ -757,6 +923,8 @@ public final class PendingMessageManager { switch context.state { case .none: continue loop + case .waitingForForwardSendGate: + continue loop case let .collectingInfo(message): if message.groupingKey == groupId { return nil @@ -773,6 +941,10 @@ public final class PendingMessageManager { if contextGroupId == groupId { result.append((context, id, content)) } + case let .waitingForSendGate(contextGroupId, content): + if contextGroupId == groupId { + result.append((context, id, content)) + } case let .sending(contextGroupId): if contextGroupId == groupId { return nil @@ -792,6 +964,16 @@ public final class PendingMessageManager { } private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) { + let firstMessageId = messages[0].messageId + let firstThreadId = messages[0].messageContext.threadId + let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId) + if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) { + for entry in messages { + entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content) + } + return + } + for (context, _, _) in messages { context.state = .sending(groupId: groupId) } From 06d744495ce7a3814ed7ccc4a28fe73a4e0641f4 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 12:14:04 +0400 Subject: [PATCH 32/69] Improve draft sorting --- .../Postbox/Sources/MessageHistoryView.swift | 6 ++-- .../State/AccountStateManagementUtils.swift | 32 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index c3f80e1ef8..f517f5b3d9 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -1560,13 +1560,15 @@ public final class MessageHistoryView: PostboxView { } if !self.holeLater, let typingDraft = mutableView.typingDraft { - entries.append(MessageHistoryEntry( + let newEntry = MessageHistoryEntry( message: typingDraft, isRead: false, location: nil, monthLocation: nil, attributes: MutableMessageHistoryEntryAttributes(authorIsContact: false) - )) + ) + entries.append(newEntry) + entries.sort() } self.entries = entries diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 89eb151625..7fde2c2909 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4018,7 +4018,7 @@ func replayFinalState( } case update(Update) - case cancel + case cancel(updatedTimestamp: Int32) } var liveTypingDraftUpdates: [PeerAndThreadId: [LiveTypingDraftUpdate]] = [:] @@ -4236,11 +4236,11 @@ func replayFinalState( let allKey = PeerAndThreadId(peerId: chatPeerId, threadId: nil) if liveTypingDraftUpdates[key] != nil { - liveTypingDraftUpdates[key] = [.cancel] - liveTypingDraftUpdates[allKey] = [.cancel] + liveTypingDraftUpdates[key] = [.cancel(updatedTimestamp: message.timestamp)] + liveTypingDraftUpdates[allKey] = [.cancel(updatedTimestamp: message.timestamp)] } else if let currentDraft = transaction.getCurrentTypingDraft(location: key) { - liveTypingDraftUpdates[key] = [.cancel] - liveTypingDraftUpdates[allKey] = [.cancel] + liveTypingDraftUpdates[key] = [.cancel(updatedTimestamp: message.timestamp)] + liveTypingDraftUpdates[allKey] = [.cancel(updatedTimestamp: message.timestamp)] messages[i] = messages[i].withUpdatedCustomStableId(currentDraft.stableId) } } @@ -6080,6 +6080,23 @@ func replayFinalState( } if !liveTypingDraftUpdates.isEmpty { + for (key, updates) in liveTypingDraftUpdates { + if key.threadId == nil { + var maxCancelledTimestamp: Int32? + for update in updates { + if case let .cancel(updatedTimestamp) = update { + if let current = maxCancelledTimestamp { + maxCancelledTimestamp = max(current, updatedTimestamp) + } else { + maxCancelledTimestamp = updatedTimestamp + } + } + } + if let maxCancelledTimestamp { + transaction.offsetPendingMessagesTimestamps(lowerBound: MessageId(peerId: key.peerId, namespace: Namespaces.Message.Local, id: 1), excludeIds: Set(), timestamp: maxCancelledTimestamp) + } + } + } transaction.combineTypingDrafts(locations: Set(liveTypingDraftUpdates.keys), update: { key, current in guard let update = liveTypingDraftUpdates[key]?.max(by: { lhs, rhs in switch lhs { @@ -6105,6 +6122,11 @@ func replayFinalState( if let current, current.id == update.id { timestamp = current.timestamp } + if current == nil { + if let index = transaction.getTopPeerMessageIndex(peerId: key.peerId) { + timestamp = max(timestamp, index.timestamp) + } + } return ( update.id, Namespaces.Message.Cloud, From 7ef7f16727b71fee2f4fcb4808c42e82ae73b16e Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 12:56:55 +0400 Subject: [PATCH 33/69] Improve animations --- .../BrowserUI/Sources/BrowserBookmarksScreen.swift | 2 +- submodules/Display/Source/ListView.swift | 4 ++-- .../Sources/ChatMessageBubbleContentNode.swift | 2 +- .../Sources/ChatMessageBubbleItemNode.swift | 14 +++++++------- .../ChatMessageFactCheckBubbleContentNode.swift | 2 +- .../Sources/ChatMessageFileBubbleContentNode.swift | 2 +- .../Sources/ChatMessageGiftBubbleContentNode.swift | 2 +- .../ChatMessageInstantVideoBubbleContentNode.swift | 10 +++++----- .../Sources/ChatMessageMapBubbleContentNode.swift | 2 +- .../Sources/ChatMessagePollBubbleContentNode.swift | 4 ++-- .../Sources/ChatMessageTextBubbleContentNode.swift | 10 +++++----- .../ChatMessageWebpageBubbleContentNode.swift | 2 +- .../Sources/ChatRecentActionsControllerNode.swift | 2 +- .../ChatSendAudioMessageContextPreview.swift | 2 +- .../Sources/ChatControllerInteraction.swift | 4 ++-- .../PeerInfoScreen/Sources/PeerInfoScreen.swift | 2 +- submodules/TelegramUI/Sources/ChatController.swift | 8 ++++---- .../TelegramUI/Sources/ChatHistoryListNode.swift | 6 +++--- .../Sources/OverlayAudioPlayerControllerNode.swift | 2 +- .../TelegramUI/Sources/SharedAccountContext.swift | 2 +- 20 files changed, 42 insertions(+), 42 deletions(-) diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index d72f35d457..85a1aaf83b 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -176,7 +176,7 @@ public final class BrowserBookmarksScreen: ViewController { }, sendGift: { _ in }, openUniqueGift: { _ in }, openMessageFeeException: { - }, requestMessageUpdate: { _, _ in + }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 3ebe7a80e2..e3e2dba497 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1856,7 +1856,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur }) } - public func transaction(deleteIndices: [ListViewDeleteItem], insertIndicesAndItems: [ListViewInsertItem], updateIndicesAndItems: [ListViewUpdateItem], options: ListViewDeleteAndInsertOptions, scrollToItem: ListViewScrollToItem? = nil, additionalScrollDistance: CGFloat = 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, stationaryItemRange: (Int, Int)? = nil, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in }) { + public func transaction(deleteIndices: [ListViewDeleteItem], insertIndicesAndItems: [ListViewInsertItem], updateIndicesAndItems: [ListViewUpdateItem], options: ListViewDeleteAndInsertOptions, scrollToItem: ListViewScrollToItem? = nil, additionalScrollDistance: CGFloat = 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, stationaryItemRange: (Int, Int)? = nil, customAnimationTransition: ControlledTransition? = nil, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in }) { if deleteIndices.isEmpty && insertIndicesAndItems.isEmpty && updateIndicesAndItems.isEmpty && scrollToItem == nil && updateSizeAndInsets == nil && additionalScrollDistance.isZero { if let updateOpaqueState = updateOpaqueState { self.opaqueTransactionState = updateOpaqueState @@ -1868,7 +1868,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur self.transactionQueue.addTransaction({ [weak self] transactionCompletion in if let strongSelf = self { strongSelf.transactionOffset = 0.0 - strongSelf.deleteAndInsertItemsTransaction(deleteIndices: deleteIndices, insertIndicesAndItems: insertIndicesAndItems, updateIndicesAndItems: updateIndicesAndItems, options: options, scrollToItem: scrollToItem, additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, updateOpaqueState: updateOpaqueState, customAnimationTransition: updateSizeAndInsets?.customAnimationTransition, completion: { [weak strongSelf] in + strongSelf.deleteAndInsertItemsTransaction(deleteIndices: deleteIndices, insertIndicesAndItems: insertIndicesAndItems, updateIndicesAndItems: updateIndicesAndItems, options: options, scrollToItem: scrollToItem, additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, updateOpaqueState: updateOpaqueState, customAnimationTransition: customAnimationTransition ?? updateSizeAndInsets?.customAnimationTransition, completion: { [weak strongSelf] in completion(strongSelf?.immediateDisplayedItemRange() ?? ListViewDisplayedItemRange(loadedRange: nil, visibleRange: nil)) transactionCompletion() diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift index 01f95aa3ff..0e849d382e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift @@ -225,7 +225,7 @@ open class ChatMessageBubbleContentNode: ASDisplayNode { public var updateIsTextSelectionActive: ((Bool) -> Void)? public var requestInlineUpdate: (() -> Void)? - public var requestFullUpdate: (() -> Void)? + public var requestFullUpdate: ((ControlledTransition?) -> Void)? open var disablesClipping: Bool { return false diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 60c7f868a5..36f8c6a08d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -4816,12 +4816,12 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI strongSelf.internalUpdateLayout() } - contentNode.requestFullUpdate = { [weak strongSelf] in + contentNode.requestFullUpdate = { [weak strongSelf] customTransition in guard let strongSelf, let item = strongSelf.item else { return } - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, customTransition) } } } @@ -5636,7 +5636,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if attribute.isQuote, !replyInfoNode.isQuoteExpanded { replyInfoNode.isQuoteExpanded = true - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) return } var progress: Promise? @@ -5668,7 +5668,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if attribute.isQuote, !replyInfoNode.isQuoteExpanded { replyInfoNode.isQuoteExpanded = true - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) return } @@ -7309,10 +7309,10 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI if item.controllerInteraction.summarizedMessageIds.contains(item.message.id) { item.controllerInteraction.summarizedMessageIds.remove(item.message.id) - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } else { item.controllerInteraction.summarizedMessageIds.insert(item.message.id) - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) let translateToLanguage = item.associatedData.translateToLanguage var requestSummary = true @@ -7327,7 +7327,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI |> deliverOnMainQueue).start(error: { error in if case .limitExceededPremium = error, let parentController = item.controllerInteraction.navigationController()?.topViewController as? ViewController { item.controllerInteraction.summarizedMessageIds.remove(item.message.id) - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) let controller = premiumAlertController( context: item.context, parentController: parentController, diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift index 33a6223ac6..7f20815c29 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFactCheckBubbleContentNode/Sources/ChatMessageFactCheckBubbleContentNode.swift @@ -134,7 +134,7 @@ public class ChatMessageFactCheckBubbleContentNode: ChatMessageBubbleContentNode guard let item = self.item else{ return } - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } public override func willUpdateIsExtractedToContextPreview(_ value: Bool) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift index 0f8680dbaf..1aebf306db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageFileBubbleContentNode/Sources/ChatMessageFileBubbleContentNode.swift @@ -54,7 +54,7 @@ public class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in if let strongSelf = self, let item = strongSelf.item { - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift index f1263d1588..bf1b1cde37 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageGiftBubbleContentNode/Sources/ChatMessageGiftBubbleContentNode.swift @@ -308,7 +308,7 @@ public class ChatMessageGiftBubbleContentNode: ChatMessageBubbleContentNode { guard let item = self.item else{ return } - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } private func makeProgress() -> Promise { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift index b7b1f1fd10..0b9936d978 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInstantVideoBubbleContentNode/Sources/ChatMessageInstantVideoBubbleContentNode.swift @@ -94,7 +94,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN self.interactiveVideoNode.requestUpdateLayout = { [weak self] _ in if let strongSelf = self, let item = strongSelf.item { - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } self.interactiveVideoNode.updateTranscriptionExpanded = { [weak self] state in @@ -102,13 +102,13 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN let previous = strongSelf.audioTranscriptionState strongSelf.audioTranscriptionState = state strongSelf.interactiveFileNode.audioTranscriptionState = state - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, state != .inProgress && previous != state) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, state != .inProgress && previous != state, nil) } } self.interactiveVideoNode.updateTranscriptionText = { [weak self] text in if let strongSelf = self, let item = strongSelf.item { strongSelf.interactiveFileNode.forcedAudioTranscriptionText = text - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } self.interactiveFileNode.updateTranscriptionExpanded = { [weak self] state in @@ -116,7 +116,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN let previous = strongSelf.audioTranscriptionState strongSelf.audioTranscriptionState = state strongSelf.interactiveVideoNode.audioTranscriptionState = state - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, previous != state) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, previous != state, nil) } } @@ -134,7 +134,7 @@ public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentN self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in if let strongSelf = self, let item = strongSelf.item { - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift index 367234a0e1..484d9a2312 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageMapBubbleContentNode/Sources/ChatMessageMapBubbleContentNode.swift @@ -439,7 +439,7 @@ public class ChatMessageMapBubbleContentNode: ChatMessageBubbleContentNode { if let strongSelf = self { strongSelf.timeoutTimer?.0.invalidate() strongSelf.timeoutTimer = nil - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } }, queue: Queue.mainQueue()) strongSelf.timeoutTimer = (timer, timeoutDeadline) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 873a2a6dab..e4a227b0e4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2165,7 +2165,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { guard let item = self.item else { return } - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } private func updatePollAddOptionFocused(_ focus: Bool) { @@ -2372,7 +2372,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { item.controllerInteraction.requestOpenMessagePollResults(item.message.id, pollId) case .anonymous: self.isPreviewingResults = !self.isPreviewingResults - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } } else if !selectedOpaqueIdentifiers.isEmpty { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index 8d8872ccd6..a8797855fe 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -197,7 +197,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } else { self.expandedBlockIds.insert(blockId) } - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } self.textNode.textNode.requestDisplayContentsUnderSpoilers = { [weak self] location in guard let self else { @@ -841,7 +841,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.relativeDateTimer = nil } strongSelf.relativeDateTimer = (SwiftSignalKit.Timer(timeout: Double(formattedDateUpdatePeriod), repeat: true, completion: { [weak self] in - self?.requestFullUpdate?() + self?.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false)) }, queue: Queue.mainQueue()), formattedDateUpdatePeriod) strongSelf.relativeDateTimer?.timer.start() } else if let (timer, _) = strongSelf.relativeDateTimer { @@ -1117,7 +1117,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut).updateAlpha(node: statusNode, alpha: 1.0) } - self.requestFullUpdate?() + self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false)) } else { var requestUpdate = false let glyphCount = textRevealAnimationState.glyphCount(timestamp: timestamp) @@ -1137,7 +1137,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } if requestUpdate { - self.requestFullUpdate?() + self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false)) } } } @@ -1737,7 +1737,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } self.displayContentsUnderSpoilers = (value, location) if let item = self.item { - item.controllerInteraction.requestMessageUpdate(item.message.id, false) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode/Sources/ChatMessageWebpageBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode/Sources/ChatMessageWebpageBubbleContentNode.swift index a6d599cff8..c0da699d9b 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode/Sources/ChatMessageWebpageBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageWebpageBubbleContentNode/Sources/ChatMessageWebpageBubbleContentNode.swift @@ -152,7 +152,7 @@ public final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContent } self.contentNode.requestUpdateLayout = { [weak self] in if let strongSelf = self, let item = strongSelf.item { - let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } self.contentNode.defaultContentAction = { [weak self] in diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index fa8ecc35fb..90557d5de4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -653,7 +653,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, sendGift: { _ in }, openUniqueGift: { _ in }, openMessageFeeException: { - }, requestMessageUpdate: { _, _ in + }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index 7253069a2e..ad1a8f0e1e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -500,7 +500,7 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, sendGift: { _ in }, openUniqueGift: { _ in }, openMessageFeeException: { - }, requestMessageUpdate: { _, _ in + }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 9477b10bf2..8f69b09ca1 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -288,7 +288,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let sendGift: (EnginePeer.Id) -> Void public let openUniqueGift: (String) -> Void public let openMessageFeeException: () -> Void - public let requestMessageUpdate: (MessageId, Bool) -> Void + public let requestMessageUpdate: (MessageId, Bool, ControlledTransition?) -> Void public let cancelInteractiveKeyboardGestures: () -> Void public let dismissTextInput: () -> Void public let scrollToMessageId: (MessageIndex) -> Void @@ -465,7 +465,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol sendGift: @escaping (EnginePeer.Id) -> Void, openUniqueGift: @escaping (String) -> Void, openMessageFeeException: @escaping () -> Void, - requestMessageUpdate: @escaping (MessageId, Bool) -> Void, + requestMessageUpdate: @escaping (MessageId, Bool, ControlledTransition?) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, dismissTextInput: @escaping () -> Void, scrollToMessageId: @escaping (MessageIndex) -> Void, diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 2e441623ba..a0b4bb2858 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1271,7 +1271,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro self.openPremiumGift() }, openUniqueGift: { _ in }, openMessageFeeException: { - }, requestMessageUpdate: { _, _ in + }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index f845e2024d..6bf848312a 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -4377,7 +4377,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.displayPollSolution(solution: solution, sourceNode: sourceNode, isAutomatic: false) } else if let messageId = self.controllerInteraction?.currentPollMessageWithTooltip { self.controllerInteraction?.currentPollMessageWithTooltip = nil - self.controllerInteraction?.requestMessageUpdate(messageId, false) + self.controllerInteraction?.requestMessageUpdate(messageId, false, nil) } }, displayPsa: { [weak self] type, sourceNode in self?.displayPsa(type: type, sourceNode: sourceNode, isAutomatic: false) @@ -5317,9 +5317,9 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self.present(controller, in: .window(.root)) }) } - }, requestMessageUpdate: { [weak self] id, scroll in + }, requestMessageUpdate: { [weak self] id, scroll, customTransition in if let self { - self.chatDisplayNode.historyNode.requestMessageUpdate(id, andScrollToItem: scroll) + self.chatDisplayNode.historyNode.requestMessageUpdate(id, andScrollToItem: scroll, customTransition: customTransition) } }, cancelInteractiveKeyboardGestures: { [weak self] in if let self { @@ -8212,7 +8212,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G let messageId = item.message.id self.controllerInteraction?.currentPollMessageWithTooltip = messageId - self.controllerInteraction?.requestMessageUpdate(messageId, false) + self.controllerInteraction?.requestMessageUpdate(messageId, false, nil) } public func displayPromoAnnouncement(text: String) { diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 8927a5af72..2ee2daf1e4 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -4749,7 +4749,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH } } - func requestMessageUpdate(_ id: MessageId, andScrollToItem scroll: Bool = false) { + func requestMessageUpdate(_ id: MessageId, andScrollToItem scroll: Bool = false, customTransition: ControlledTransition? = nil) { if let historyView = self.historyView { var messageItem: ChatMessageItem? self.forEachItemNode({ itemNode in @@ -4795,7 +4795,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true) } - self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, customAnimationTransition: customTransition, updateOpaqueState: nil, completion: { _ in }) break loop } case let .MessageGroupEntry(_, messages, presentationData): @@ -4816,7 +4816,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true) } - self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, customAnimationTransition: customTransition, updateOpaqueState: nil, completion: { _ in }) break loop } default: diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index bb46df404b..7fecf934f3 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -245,7 +245,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, sendGift: { _ in }, openUniqueGift: { _ in }, openMessageFeeException: { - }, requestMessageUpdate: { _, _ in + }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { }, scrollToMessageId: { _ in diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 2e9920d1fa..8fabe4977e 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2554,7 +2554,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, openMessageFeeException: { }, - requestMessageUpdate: { _, _ in + requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, From b22238792301b17579096f5c5dc8faaa8add1e27 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 12:14:39 +0200 Subject: [PATCH 34/69] Make origin check optional --- .../WebUI/Sources/WebAppController.swift | 7 ++++- submodules/WebUI/Sources/WebAppWebView.swift | 27 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 90202b3f4c..64616b7429 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -516,7 +516,12 @@ public final class WebAppController: ViewController, AttachmentContainable { return } #endif*/ - self.webView?.bindTrustedOrigin(from: url) + + if !"".isEmpty { + self.webView?.bindTrustedOrigin(from: url) + } else { + self.webView?.setupEventProxySource() + } self.webView?.load(URLRequest(url: url)) } diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index f722ace6e7..edf115e500 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -43,7 +43,19 @@ private func jsStringLiteral(_ value: String) -> String { return "\"\"" } -private func eventProxySource(trustedOrigin: String) -> String { +private func eventProxySource() -> String { + return """ + (function() { + var TelegramWebviewProxyProto = function() {}; + TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { + window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); + }; + window.TelegramWebviewProxy = new TelegramWebviewProxyProto(); + })(); + """ +} + +private func securedEventProxySource(trustedOrigin: String) -> String { return """ (function() { if (window.location.origin !== \(jsStringLiteral(trustedOrigin))) { @@ -202,6 +214,7 @@ final class WebAppWebView: WKWebView { print() } + var useSecuredEventProxy = true func bindTrustedOrigin(from url: URL) { guard self.trustedOrigin == nil else { return @@ -212,7 +225,14 @@ final class WebAppWebView: WKWebView { self.trustedOrigin = origin - let eventProxyScript = WKUserScript(source: eventProxySource(trustedOrigin: origin), injectionTime: .atDocumentStart, forMainFrameOnly: true) + let eventProxyScript = WKUserScript(source: securedEventProxySource(trustedOrigin: origin), injectionTime: .atDocumentStart, forMainFrameOnly: true) + self.configuration.userContentController.addUserScript(eventProxyScript) + } + + func setupEventProxySource() { + self.useSecuredEventProxy = false + + let eventProxyScript = WKUserScript(source: eventProxySource(), injectionTime: .atDocumentStart, forMainFrameOnly: true) self.configuration.userContentController.addUserScript(eventProxyScript) } @@ -220,6 +240,9 @@ final class WebAppWebView: WKWebView { guard message.frameInfo.isMainFrame else { return false } + if !self.useSecuredEventProxy { + return true + } guard let trustedOrigin = self.trustedOrigin else { return false } From 330fed199d615403adf68c47d0c1f0a76db07286 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 12:39:54 +0200 Subject: [PATCH 35/69] Improve quick share toast --- .../Sources/QuickShareToastScreen.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift index 723007aa78..baafd5d89b 100644 --- a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift +++ b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift @@ -254,16 +254,19 @@ private final class QuickShareToastScreenComponent: Component { let contentSize = self.content.update( transition: transition, - component: AnyComponent(MultilineTextComponent(text: .markdown( - text: tooltipText, - attributes: MarkdownAttributes( - body: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), - bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: environment.theme.list.itemAccentColor.withMultiplied(hue: 0.933, saturation: 0.61, brightness: 1.0)), - link: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), - linkAttribute: { _ in return nil }) - ))), + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: tooltipText, + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), + bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: environment.theme.list.itemAccentColor.withMultiplied(hue: 0.933, saturation: 0.61, brightness: 1.0)), + link: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), + linkAttribute: { _ in return nil }) + ), + maximumNumberOfLines: 2 + )), environment: {}, - containerSize: CGSize(width: availableContentSize.width - contentInsets.left - contentInsets.right - spacing - iconSize.width - actionButtonSize.width - 16.0 - 4.0, height: availableContentSize.height) + containerSize: CGSize(width: availableContentSize.width - contentInsets.left - contentInsets.right - spacing - iconSize.width - actionButtonSize.width - 16.0 - 8.0, height: availableContentSize.height) ) var contentHeight: CGFloat = 0.0 @@ -313,7 +316,7 @@ private final class QuickShareToastScreenComponent: Component { self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition) - self.backgroundView.update(size: backgroundFrame.size, cornerRadius: 14.0, transition: transition.containedViewLayoutTransition) + self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, transition: transition.containedViewLayoutTransition) transition.setFrame(view: self.backgroundView, frame: backgroundFrame) transition.setFrame(view: self.contentView, frame: CGRect(origin: .zero, size: backgroundFrame.size)) From 29ecff37281340e0c9a03bf3770b80dd5b6f9a83 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 12:43:12 +0200 Subject: [PATCH 36/69] Various fixes --- .../Sources/Node/ChatListItem.swift | 8 ++++-- .../Sources/QuickShareToastScreen.swift | 23 +++++++++------- .../WebUI/Sources/WebAppController.swift | 7 ++++- submodules/WebUI/Sources/WebAppWebView.swift | 27 +++++++++++++++++-- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 2e76203112..abc01321fb 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -2589,6 +2589,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if case .user = itemPeer.chatMainPeer { isUser = true } + var isGuestChatAuthor = false + if case let .user(user) = messages.last?.author, let botInfo = user.botInfo, botInfo.flags.contains(.isGuestChat) { + isGuestChatAuthor = true + } var peerText: String? if case .savedMessagesChats = item.chatListLocation { @@ -2606,14 +2610,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if let message = messages.last, let forwardInfo = message.forwardInfo, let author = forwardInfo.author { peerText = EnginePeer(author).compactDisplayTitle } - } else if !isUser { + } else if !isUser || isGuestChatAuthor { if case let .channel(peer) = peer, case .broadcast = peer.info { } else if !displayAsMessage { if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported), let authorSignature = forwardInfo.authorSignature { peerText = authorSignature } else { peerText = author.id == account.peerId ? item.presentationData.strings.DialogList_You : EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) - authorIsCurrentChat = author.id == peer.id + authorIsCurrentChat = !isGuestChatAuthor && author.id == peer.id } } } diff --git a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift index 723007aa78..baafd5d89b 100644 --- a/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift +++ b/submodules/TelegramUI/Components/Chat/QuickShareScreen/Sources/QuickShareToastScreen.swift @@ -254,16 +254,19 @@ private final class QuickShareToastScreenComponent: Component { let contentSize = self.content.update( transition: transition, - component: AnyComponent(MultilineTextComponent(text: .markdown( - text: tooltipText, - attributes: MarkdownAttributes( - body: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), - bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: environment.theme.list.itemAccentColor.withMultiplied(hue: 0.933, saturation: 0.61, brightness: 1.0)), - link: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), - linkAttribute: { _ in return nil }) - ))), + component: AnyComponent(MultilineTextComponent( + text: .markdown( + text: tooltipText, + attributes: MarkdownAttributes( + body: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), + bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: environment.theme.list.itemAccentColor.withMultiplied(hue: 0.933, saturation: 0.61, brightness: 1.0)), + link: MarkdownAttributeSet(font: Font.regular(14.0), textColor: .white), + linkAttribute: { _ in return nil }) + ), + maximumNumberOfLines: 2 + )), environment: {}, - containerSize: CGSize(width: availableContentSize.width - contentInsets.left - contentInsets.right - spacing - iconSize.width - actionButtonSize.width - 16.0 - 4.0, height: availableContentSize.height) + containerSize: CGSize(width: availableContentSize.width - contentInsets.left - contentInsets.right - spacing - iconSize.width - actionButtonSize.width - 16.0 - 8.0, height: availableContentSize.height) ) var contentHeight: CGFloat = 0.0 @@ -313,7 +316,7 @@ private final class QuickShareToastScreenComponent: Component { self.backgroundView.updateColor(color: UIColor(white: 0.0, alpha: 0.7), transition: transition.containedViewLayoutTransition) - self.backgroundView.update(size: backgroundFrame.size, cornerRadius: 14.0, transition: transition.containedViewLayoutTransition) + self.backgroundView.update(size: backgroundFrame.size, cornerRadius: backgroundFrame.height * 0.5, transition: transition.containedViewLayoutTransition) transition.setFrame(view: self.backgroundView, frame: backgroundFrame) transition.setFrame(view: self.contentView, frame: CGRect(origin: .zero, size: backgroundFrame.size)) diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 90202b3f4c..64616b7429 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -516,7 +516,12 @@ public final class WebAppController: ViewController, AttachmentContainable { return } #endif*/ - self.webView?.bindTrustedOrigin(from: url) + + if !"".isEmpty { + self.webView?.bindTrustedOrigin(from: url) + } else { + self.webView?.setupEventProxySource() + } self.webView?.load(URLRequest(url: url)) } diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index f722ace6e7..edf115e500 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -43,7 +43,19 @@ private func jsStringLiteral(_ value: String) -> String { return "\"\"" } -private func eventProxySource(trustedOrigin: String) -> String { +private func eventProxySource() -> String { + return """ + (function() { + var TelegramWebviewProxyProto = function() {}; + TelegramWebviewProxyProto.prototype.postEvent = function(eventName, eventData) { + window.webkit.messageHandlers.performAction.postMessage({'eventName': eventName, 'eventData': eventData}); + }; + window.TelegramWebviewProxy = new TelegramWebviewProxyProto(); + })(); + """ +} + +private func securedEventProxySource(trustedOrigin: String) -> String { return """ (function() { if (window.location.origin !== \(jsStringLiteral(trustedOrigin))) { @@ -202,6 +214,7 @@ final class WebAppWebView: WKWebView { print() } + var useSecuredEventProxy = true func bindTrustedOrigin(from url: URL) { guard self.trustedOrigin == nil else { return @@ -212,7 +225,14 @@ final class WebAppWebView: WKWebView { self.trustedOrigin = origin - let eventProxyScript = WKUserScript(source: eventProxySource(trustedOrigin: origin), injectionTime: .atDocumentStart, forMainFrameOnly: true) + let eventProxyScript = WKUserScript(source: securedEventProxySource(trustedOrigin: origin), injectionTime: .atDocumentStart, forMainFrameOnly: true) + self.configuration.userContentController.addUserScript(eventProxyScript) + } + + func setupEventProxySource() { + self.useSecuredEventProxy = false + + let eventProxyScript = WKUserScript(source: eventProxySource(), injectionTime: .atDocumentStart, forMainFrameOnly: true) self.configuration.userContentController.addUserScript(eventProxyScript) } @@ -220,6 +240,9 @@ final class WebAppWebView: WKWebView { guard message.frameInfo.isMainFrame else { return false } + if !self.useSecuredEventProxy { + return true + } guard let trustedOrigin = self.trustedOrigin else { return false } From 266b1c59417487f913288a92329e1214609f96b1 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 14:43:41 +0400 Subject: [PATCH 37/69] Fix ListViewProtocol --- .../Display/Source/ContainedViewLayoutTransition.swift | 9 +++++++-- submodules/Display/Source/ListViewProtocol.swift | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index ceb65eb6fd..8ec45f3bb9 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -408,7 +408,7 @@ public extension ContainedViewLayoutTransition { } } - func updateBounds(layer: CALayer, bounds: CGRect, force: Bool = false, completion: ((Bool) -> Void)? = nil) { + func updateBounds(layer: CALayer, bounds: CGRect, beginWithCurrentState: Bool = false, force: Bool = false, completion: ((Bool) -> Void)? = nil) { if layer.bounds.equalTo(bounds) && !force { completion?(true) } else { @@ -420,7 +420,12 @@ public extension ContainedViewLayoutTransition { completion(true) } case let .animated(duration, curve): - let previousBounds = layer.bounds + let previousBounds: CGRect + if beginWithCurrentState, layer.animation(forKey: "position") != nil, let presentation = layer.presentation() { + previousBounds = presentation.bounds + } else { + previousBounds = layer.bounds + } layer.bounds = bounds layer.animateBounds(from: previousBounds, to: bounds, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, force: force, completion: { result in if let completion = completion { diff --git a/submodules/Display/Source/ListViewProtocol.swift b/submodules/Display/Source/ListViewProtocol.swift index 51a838d7e4..dbfac0920a 100644 --- a/submodules/Display/Source/ListViewProtocol.swift +++ b/submodules/Display/Source/ListViewProtocol.swift @@ -67,6 +67,7 @@ public protocol ListView: ASDisplayNode { additionalScrollDistance: CGFloat, updateSizeAndInsets: ListViewUpdateSizeAndInsets?, stationaryItemRange: (Int, Int)?, + customAnimationTransition: ControlledTransition?, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void ) @@ -102,6 +103,7 @@ public extension ListView { additionalScrollDistance: CGFloat = 0.0, updateSizeAndInsets: ListViewUpdateSizeAndInsets? = nil, stationaryItemRange: (Int, Int)? = nil, + customAnimationTransition: ControlledTransition? = nil, updateOpaqueState: Any?, completion: @escaping (ListViewDisplayedItemRange) -> Void = { _ in } ) { @@ -114,6 +116,7 @@ public extension ListView { additionalScrollDistance: additionalScrollDistance, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: stationaryItemRange, + customAnimationTransition: customAnimationTransition, updateOpaqueState: updateOpaqueState, completion: completion ) From 5588a3996bb470b27564b83453df7627afa539a4 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 14:49:43 +0400 Subject: [PATCH 38/69] Update tgcalls --- submodules/TgVoipWebrtc/tgcalls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index a6ea40ebf1..43d03b0b82 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit a6ea40ebf18233fad5aae29aa3d30561f839a6a8 +Subproject commit 43d03b0b827be372fed73ad8a31405d5a74028a4 From 4e81b79254422134cf211a2d2de1198137ae9f52 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 14:53:16 +0400 Subject: [PATCH 39/69] Revert "Update tgcalls" This reverts commit 5588a3996bb470b27564b83453df7627afa539a4. --- submodules/TgVoipWebrtc/tgcalls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index 43d03b0b82..a6ea40ebf1 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit 43d03b0b827be372fed73ad8a31405d5a74028a4 +Subproject commit a6ea40ebf18233fad5aae29aa3d30561f839a6a8 From 5a2d6658e7ff2a9bf3109d662f62ab88e99c966a Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 13:12:46 +0200 Subject: [PATCH 40/69] Fix time-limited poll closing --- .../TelegramCore/Sources/TelegramEngine/Messages/Polls.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift index 355be9c09f..5ec6539f0e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift @@ -266,7 +266,7 @@ func _internal_requestClosePoll(postbox: Postbox, network: Network, stateManager pollMediaFlags |= 1 << 1 } - return network.request(Api.functions.messages.editMessage(flags: flags, peer: inputPeer, id: messageId.id, message: nil, media: .inputMediaPoll(.init(flags: pollMediaFlags, poll: .poll(.init(id: poll.pollId.id, flags: pollFlags, question: .textWithEntities(.init(text: poll.text, entities: apiEntitiesFromMessageTextEntities(poll.textEntities, associatedPeers: SimpleDictionary()))), answers: poll.options.map({ $0.apiOption }), closePeriod: poll.deadlineTimeout, closeDate: nil, countriesIso2: poll.countries, hash: 0)), correctAnswers: correctAnswersIndices, attachedMedia: nil, solution: mappedSolution, solutionEntities: mappedSolutionEntities, solutionMedia: nil)), replyMarkup: nil, entities: nil, scheduleDate: nil, scheduleRepeatPeriod: nil, quickReplyShortcutId: nil)) + return network.request(Api.functions.messages.editMessage(flags: flags, peer: inputPeer, id: messageId.id, message: nil, media: .inputMediaPoll(.init(flags: pollMediaFlags, poll: .poll(.init(id: poll.pollId.id, flags: pollFlags, question: .textWithEntities(.init(text: poll.text, entities: apiEntitiesFromMessageTextEntities(poll.textEntities, associatedPeers: SimpleDictionary()))), answers: poll.options.map({ $0.apiOption }), closePeriod: poll.deadlineTimeout, closeDate: poll.deadlineDate, countriesIso2: poll.countries, hash: 0)), correctAnswers: correctAnswersIndices, attachedMedia: nil, solution: mappedSolution, solutionEntities: mappedSolutionEntities, solutionMedia: nil)), replyMarkup: nil, entities: nil, scheduleDate: nil, scheduleRepeatPeriod: nil, quickReplyShortcutId: nil)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) From 9c3cab27e148e9763bd7891fc3113100233d77b0 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 13:14:56 +0200 Subject: [PATCH 41/69] Fix time-limited poll closing --- .../TelegramCore/Sources/TelegramEngine/Messages/Polls.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift index 355be9c09f..5ec6539f0e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift @@ -266,7 +266,7 @@ func _internal_requestClosePoll(postbox: Postbox, network: Network, stateManager pollMediaFlags |= 1 << 1 } - return network.request(Api.functions.messages.editMessage(flags: flags, peer: inputPeer, id: messageId.id, message: nil, media: .inputMediaPoll(.init(flags: pollMediaFlags, poll: .poll(.init(id: poll.pollId.id, flags: pollFlags, question: .textWithEntities(.init(text: poll.text, entities: apiEntitiesFromMessageTextEntities(poll.textEntities, associatedPeers: SimpleDictionary()))), answers: poll.options.map({ $0.apiOption }), closePeriod: poll.deadlineTimeout, closeDate: nil, countriesIso2: poll.countries, hash: 0)), correctAnswers: correctAnswersIndices, attachedMedia: nil, solution: mappedSolution, solutionEntities: mappedSolutionEntities, solutionMedia: nil)), replyMarkup: nil, entities: nil, scheduleDate: nil, scheduleRepeatPeriod: nil, quickReplyShortcutId: nil)) + return network.request(Api.functions.messages.editMessage(flags: flags, peer: inputPeer, id: messageId.id, message: nil, media: .inputMediaPoll(.init(flags: pollMediaFlags, poll: .poll(.init(id: poll.pollId.id, flags: pollFlags, question: .textWithEntities(.init(text: poll.text, entities: apiEntitiesFromMessageTextEntities(poll.textEntities, associatedPeers: SimpleDictionary()))), answers: poll.options.map({ $0.apiOption }), closePeriod: poll.deadlineTimeout, closeDate: poll.deadlineDate, countriesIso2: poll.countries, hash: 0)), correctAnswers: correctAnswersIndices, attachedMedia: nil, solution: mappedSolution, solutionEntities: mappedSolutionEntities, solutionMedia: nil)), replyMarkup: nil, entities: nil, scheduleDate: nil, scheduleRepeatPeriod: nil, quickReplyShortcutId: nil)) |> map(Optional.init) |> `catch` { _ -> Signal in return .single(nil) From e8baaa5a22287434d81b6dbe2fa25eb9f25d3417 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 15:36:13 +0200 Subject: [PATCH 42/69] Poll improvements --- .../Sources/BrowserBookmarksScreen.swift | 5 +- .../TelegramEngine/Messages/Polls.swift | 6 +- .../ChatMessagePollBubbleContentNode.swift | 44 +------------ .../ChatRecentActionsControllerNode.swift | 5 +- .../ChatSendAudioMessageContextPreview.swift | 5 +- .../Sources/ChatControllerInteraction.swift | 3 + .../Sources/PeerInfoScreen.swift | 5 +- .../Sources/Chat/ChatControllerToasts.swift | 61 +++++++++++++++++++ .../TelegramUI/Sources/ChatController.swift | 15 ++++- .../OverlayAudioPlayerControllerNode.swift | 5 +- .../Sources/SharedAccountContext.swift | 5 +- 11 files changed, 107 insertions(+), 52 deletions(-) diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index 85a1aaf83b..bc84de9157 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -189,7 +189,10 @@ public final class BrowserBookmarksScreen: ViewController { }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) let tagMask: MessageTags = .webPage diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift index 5ec6539f0e..a1a87ca53f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift @@ -31,6 +31,7 @@ func pollCloudMediaToInputMedia(_ media: Media) -> Api.InputMedia? { public enum RequestMessageSelectPollOptionError { case generic + case restrictedToSubscribers } func _internal_requestMessageSelectPollOption(account: Account, messageId: MessageId, opaqueIdentifiers: [Data]) -> Signal { @@ -40,7 +41,10 @@ func _internal_requestMessageSelectPollOption(account: Account, messageId: Messa |> mapToSignal { peer in if let inputPeer = apiInputPeer(peer) { return account.network.request(Api.functions.messages.sendVote(peer: inputPeer, msgId: messageId.id, options: opaqueIdentifiers.map { Buffer(data: $0) })) - |> mapError { _ -> RequestMessageSelectPollOptionError in + |> mapError { error -> RequestMessageSelectPollOptionError in + if error.errorDescription == "POLL_MEMBER_RESTRICTED" { + return .restrictedToSubscribers + } return .generic } |> mapToSignal { result -> Signal in diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index e4a227b0e4..1be7762dff 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2918,49 +2918,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if case .public = poll.publicity { item.controllerInteraction.openMessagePollResults(item.message.id, option.opaqueIdentifier) } else if isRestricted { - let text: String - - let peerName = item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" - if !poll.countries.isEmpty { - let locale = localeWithStrings(item.presentationData.strings) - let countryNames = poll.countries.map { id in - if id == "FT" { - return "Fragment" - } else if let countryName = locale.localizedString(forRegionCode: id) { - return countryName - } else { - return id - } - } - var countries: String = "" - if countryNames.count == 1, let country = countryNames.first { - countries = "**\(country)**" - } else { - for i in 0 ..< countryNames.count { - countries.append("**\(countryNames[i])**") - if i == countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) - } else if i < countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) - } - } - } - if poll.restrictToSubscribers { - text = item.presentationData.strings.Chat_Poll_Restriction_SubscribersCountry(peerName, countries).string - } else { - text = item.presentationData.strings.Chat_Poll_Restriction_Country(countries).string - } - } else { - text = item.presentationData.strings.Chat_Poll_Restriction_Subscribers(peerName).string - } - let controller = UndoOverlayController( - presentationData: item.context.sharedContext.currentPresentationData.with { $0 }, - content: .banned(text: text), - elevatedLayout: true, - position: .bottom, - action: { _ in return true } - ) - item.controllerInteraction.presentController(controller, nil) + item.controllerInteraction.displayPollRestrictedToast(item.message.id) } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index 90557d5de4..e1408ee47d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -666,7 +666,10 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: self.backgroundNode)) self.controllerInteraction = controllerInteraction diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index ad1a8f0e1e..f5069b7768 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -513,7 +513,10 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: self.context, backgroundNode: self.wallpaperBackgroundNode)) let associatedData = ChatMessageItemAssociatedData( diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 8f69b09ca1..168605725f 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -303,6 +303,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let openStarsPurchase: (Int64?) -> Void public let openRankInfo: (EnginePeer, ChatRankInfoScreenRole, String) -> Void public let openSetPeerAvatar: () -> Void + public let displayPollRestrictedToast: (EngineMessage.Id) -> Void public var canPlayMedia: Bool = false public var hiddenMedia: [MessageId: [Media]] = [:] @@ -480,6 +481,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol openStarsPurchase: @escaping (Int64?) -> Void, openRankInfo: @escaping (EnginePeer, ChatRankInfoScreenRole, String) -> Void, openSetPeerAvatar: @escaping () -> Void, + displayPollRestrictedToast: @escaping (EngineMessage.Id) -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings, @@ -610,6 +612,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.openStarsPurchase = openStarsPurchase self.openRankInfo = openRankInfo self.openSetPeerAvatar = openSetPeerAvatar + self.displayPollRestrictedToast = displayPollRestrictedToast self.automaticMediaDownloadSettings = automaticMediaDownloadSettings diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index a0b4bb2858..6c61401182 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1284,7 +1284,10 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().startStrict(next: { [weak self] ids in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift index 754f6996c2..16f49f9ca6 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift @@ -8,6 +8,7 @@ import Display import UndoUI import AccountContext import ChatControllerInteraction +import TelegramStringFormatting extension ChatControllerImpl { func displaySendReactionRestrictedToast() { @@ -20,6 +21,66 @@ extension ChatControllerImpl { action: { _ in return true } ), nil) } + + func displayPollRestrictedToast(messageId: EngineMessage.Id) { + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Messages.Message(id: messageId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] message in + guard let self, let message else { + return + } + let peerName = message.peers[message.id.peerId].flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" + guard let poll = message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll else { + return + } + + var accountCountry = "" + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let country = data["phone_country_iso2"] as? String { + accountCountry = (country) + } + var text = "" + if !poll.countries.isEmpty && !poll.countries.contains(accountCountry) { + let locale = localeWithStrings(self.presentationData.strings) + let countryNames = poll.countries.map { id in + if id == "FT" { + return "Fragment" + } else if let countryName = locale.localizedString(forRegionCode: id) { + return countryName + } else { + return id + } + } + var countries: String = "" + if countryNames.count == 1, let country = countryNames.first { + countries = "**\(country)**" + } else { + for i in 0 ..< countryNames.count { + countries.append("**\(countryNames[i])**") + if i == countryNames.count - 2 { + countries.append(self.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) + } else if i < countryNames.count - 2 { + countries.append(self.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) + } + } + } + if poll.restrictToSubscribers { + text = self.presentationData.strings.Chat_Poll_Restriction_SubscribersCountry(peerName, countries).string + } else { + text = self.presentationData.strings.Chat_Poll_Restriction_Country(countries).string + } + } else { + text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers_TimeLimit + } + let controller = UndoOverlayController( + presentationData: self.presentationData, + content: .banned(text: text), + position: .bottom, + action: { _ in return true } + ) + self.controllerInteraction?.presentControllerInCurrent(controller, nil) + }) + } func displayPostedScheduledMessagesToast(ids: [EngineMessage.Id]) { let timestamp = CFAbsoluteTimeGetCurrent() diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 6bf848312a..68457c5b26 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3896,20 +3896,26 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) strongSelf.present(controller, in: .current) } - }, error: { _ in + }, error: { error in guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { return } if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) } + + switch error { + case .restrictedToSubscribers: + strongSelf.displayPollRestrictedToast(messageId: id) + default: + break + } }, completed: { guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { return } if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { Queue.mainQueue().after(1.0, { - strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) }) } @@ -5664,6 +5670,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } self.interfaceInteraction?.openSetPeerAvatar() + }, displayPollRestrictedToast: { [weak self] messageId in + guard let self else { + return + } + self.displayPollRestrictedToast(messageId: messageId) }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode)) controllerInteraction.enableFullTranslucency = context.sharedContext.energyUsageSettings.fullTranslucency diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index 7fecf934f3..9fa74bbce6 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -258,7 +258,10 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 8fabe4977e..223c0af0a8 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2581,7 +2581,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { openStarsPurchase: { _ in }, openRankInfo: { _, _, _ in - }, openSetPeerAvatar: { + }, + openSetPeerAvatar: { + }, + displayPollRestrictedToast: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), From 4ca7d4231ba7d73f174015100771b329bdb68931 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 15:42:18 +0200 Subject: [PATCH 43/69] Poll improvements --- .../Sources/Chat/ChatControllerToasts.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift index 16f49f9ca6..8e37932d93 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift @@ -24,13 +24,14 @@ extension ChatControllerImpl { func displayPollRestrictedToast(messageId: EngineMessage.Id) { let _ = (self.context.engine.data.get( - TelegramEngine.EngineData.Item.Messages.Message(id: messageId) + TelegramEngine.EngineData.Item.Messages.Message(id: messageId), + TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) ) - |> deliverOnMainQueue).startStandalone(next: { [weak self] message in - guard let self, let message else { + |> deliverOnMainQueue).startStandalone(next: { [weak self] message, peer in + guard let self, let message, let peer else { return } - let peerName = message.peers[message.id.peerId].flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" + let peerName = peer.compactDisplayTitle guard let poll = message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll else { return } @@ -70,7 +71,11 @@ extension ChatControllerImpl { text = self.presentationData.strings.Chat_Poll_Restriction_Country(countries).string } } else { - text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers_TimeLimit + if case let .channel(channel) = peer, case .member = channel.participationStatus { + text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers_TimeLimit + } else { + text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers(peerName).string + } } let controller = UndoOverlayController( presentationData: self.presentationData, From 0664197b5324367b6385b107b0da3c1692acd97b Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 15:43:05 +0200 Subject: [PATCH 44/69] Poll improvements --- .../Sources/BrowserBookmarksScreen.swift | 5 +- .../TelegramEngine/Messages/Polls.swift | 6 +- .../ChatMessagePollBubbleContentNode.swift | 44 +------------ .../ChatRecentActionsControllerNode.swift | 5 +- .../ChatSendAudioMessageContextPreview.swift | 5 +- .../Sources/ChatControllerInteraction.swift | 3 + .../Sources/PeerInfoScreen.swift | 5 +- .../Sources/Chat/ChatControllerToasts.swift | 66 +++++++++++++++++++ .../TelegramUI/Sources/ChatController.swift | 15 ++++- .../OverlayAudioPlayerControllerNode.swift | 5 +- .../Sources/SharedAccountContext.swift | 5 +- 11 files changed, 112 insertions(+), 52 deletions(-) diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index d72f35d457..05bdf6b4b2 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -189,7 +189,10 @@ public final class BrowserBookmarksScreen: ViewController { }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) let tagMask: MessageTags = .webPage diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift index 5ec6539f0e..a1a87ca53f 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Polls.swift @@ -31,6 +31,7 @@ func pollCloudMediaToInputMedia(_ media: Media) -> Api.InputMedia? { public enum RequestMessageSelectPollOptionError { case generic + case restrictedToSubscribers } func _internal_requestMessageSelectPollOption(account: Account, messageId: MessageId, opaqueIdentifiers: [Data]) -> Signal { @@ -40,7 +41,10 @@ func _internal_requestMessageSelectPollOption(account: Account, messageId: Messa |> mapToSignal { peer in if let inputPeer = apiInputPeer(peer) { return account.network.request(Api.functions.messages.sendVote(peer: inputPeer, msgId: messageId.id, options: opaqueIdentifiers.map { Buffer(data: $0) })) - |> mapError { _ -> RequestMessageSelectPollOptionError in + |> mapError { error -> RequestMessageSelectPollOptionError in + if error.errorDescription == "POLL_MEMBER_RESTRICTED" { + return .restrictedToSubscribers + } return .generic } |> mapToSignal { result -> Signal in diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index 873a2a6dab..3ef4bd9cfc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2918,49 +2918,7 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { if case .public = poll.publicity { item.controllerInteraction.openMessagePollResults(item.message.id, option.opaqueIdentifier) } else if isRestricted { - let text: String - - let peerName = item.message.peers[item.message.id.peerId].flatMap(EnginePeer.init)?.compactDisplayTitle ?? "" - if !poll.countries.isEmpty { - let locale = localeWithStrings(item.presentationData.strings) - let countryNames = poll.countries.map { id in - if id == "FT" { - return "Fragment" - } else if let countryName = locale.localizedString(forRegionCode: id) { - return countryName - } else { - return id - } - } - var countries: String = "" - if countryNames.count == 1, let country = countryNames.first { - countries = "**\(country)**" - } else { - for i in 0 ..< countryNames.count { - countries.append("**\(countryNames[i])**") - if i == countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) - } else if i < countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) - } - } - } - if poll.restrictToSubscribers { - text = item.presentationData.strings.Chat_Poll_Restriction_SubscribersCountry(peerName, countries).string - } else { - text = item.presentationData.strings.Chat_Poll_Restriction_Country(countries).string - } - } else { - text = item.presentationData.strings.Chat_Poll_Restriction_Subscribers(peerName).string - } - let controller = UndoOverlayController( - presentationData: item.context.sharedContext.currentPresentationData.with { $0 }, - content: .banned(text: text), - elevatedLayout: true, - position: .bottom, - action: { _ in return true } - ) - item.controllerInteraction.presentController(controller, nil) + item.controllerInteraction.displayPollRestrictedToast(item.message.id) } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index fa8ecc35fb..604f741897 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -666,7 +666,10 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: self.backgroundNode)) self.controllerInteraction = controllerInteraction diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index 7253069a2e..1ebced67c2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -513,7 +513,10 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: self.context, backgroundNode: self.wallpaperBackgroundNode)) let associatedData = ChatMessageItemAssociatedData( diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 9477b10bf2..fc04218bf9 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -303,6 +303,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let openStarsPurchase: (Int64?) -> Void public let openRankInfo: (EnginePeer, ChatRankInfoScreenRole, String) -> Void public let openSetPeerAvatar: () -> Void + public let displayPollRestrictedToast: (EngineMessage.Id) -> Void public var canPlayMedia: Bool = false public var hiddenMedia: [MessageId: [Media]] = [:] @@ -480,6 +481,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol openStarsPurchase: @escaping (Int64?) -> Void, openRankInfo: @escaping (EnginePeer, ChatRankInfoScreenRole, String) -> Void, openSetPeerAvatar: @escaping () -> Void, + displayPollRestrictedToast: @escaping (EngineMessage.Id) -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings, @@ -610,6 +612,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.openStarsPurchase = openStarsPurchase self.openRankInfo = openRankInfo self.openSetPeerAvatar = openSetPeerAvatar + self.displayPollRestrictedToast = displayPollRestrictedToast self.automaticMediaDownloadSettings = automaticMediaDownloadSettings diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 2e441623ba..4dfd477063 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1284,7 +1284,10 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().startStrict(next: { [weak self] ids in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift index 754f6996c2..8e37932d93 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerToasts.swift @@ -8,6 +8,7 @@ import Display import UndoUI import AccountContext import ChatControllerInteraction +import TelegramStringFormatting extension ChatControllerImpl { func displaySendReactionRestrictedToast() { @@ -20,6 +21,71 @@ extension ChatControllerImpl { action: { _ in return true } ), nil) } + + func displayPollRestrictedToast(messageId: EngineMessage.Id) { + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Messages.Message(id: messageId), + TelegramEngine.EngineData.Item.Peer.Peer(id: messageId.peerId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] message, peer in + guard let self, let message, let peer else { + return + } + let peerName = peer.compactDisplayTitle + guard let poll = message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll else { + return + } + + var accountCountry = "" + if let data = self.context.currentAppConfiguration.with({ $0 }).data, let country = data["phone_country_iso2"] as? String { + accountCountry = (country) + } + var text = "" + if !poll.countries.isEmpty && !poll.countries.contains(accountCountry) { + let locale = localeWithStrings(self.presentationData.strings) + let countryNames = poll.countries.map { id in + if id == "FT" { + return "Fragment" + } else if let countryName = locale.localizedString(forRegionCode: id) { + return countryName + } else { + return id + } + } + var countries: String = "" + if countryNames.count == 1, let country = countryNames.first { + countries = "**\(country)**" + } else { + for i in 0 ..< countryNames.count { + countries.append("**\(countryNames[i])**") + if i == countryNames.count - 2 { + countries.append(self.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) + } else if i < countryNames.count - 2 { + countries.append(self.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) + } + } + } + if poll.restrictToSubscribers { + text = self.presentationData.strings.Chat_Poll_Restriction_SubscribersCountry(peerName, countries).string + } else { + text = self.presentationData.strings.Chat_Poll_Restriction_Country(countries).string + } + } else { + if case let .channel(channel) = peer, case .member = channel.participationStatus { + text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers_TimeLimit + } else { + text = self.presentationData.strings.Chat_Poll_Restriction_Subscribers(peerName).string + } + } + let controller = UndoOverlayController( + presentationData: self.presentationData, + content: .banned(text: text), + position: .bottom, + action: { _ in return true } + ) + self.controllerInteraction?.presentControllerInCurrent(controller, nil) + }) + } func displayPostedScheduledMessagesToast(ids: [EngineMessage.Id]) { let timestamp = CFAbsoluteTimeGetCurrent() diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index f845e2024d..f48dc84ed4 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3896,20 +3896,26 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G ) strongSelf.present(controller, in: .current) } - }, error: { _ in + }, error: { error in guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { return } if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) } + + switch error { + case .restrictedToSubscribers: + strongSelf.displayPollRestrictedToast(messageId: id) + default: + break + } }, completed: { guard let strongSelf = self, let controllerInteraction = strongSelf.controllerInteraction else { return } if controllerInteraction.pollActionState.pollMessageIdsInProgress.removeValue(forKey: id) != nil { Queue.mainQueue().after(1.0, { - strongSelf.chatDisplayNode.historyNode.requestMessageUpdate(id) }) } @@ -5664,6 +5670,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G return } self.interfaceInteraction?.openSetPeerAvatar() + }, displayPollRestrictedToast: { [weak self] messageId in + guard let self else { + return + } + self.displayPollRestrictedToast(messageId: messageId) }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode)) controllerInteraction.enableFullTranslucency = context.sharedContext.energyUsageSettings.fullTranslucency diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index bb46df404b..fb2f379047 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -258,7 +258,10 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestToggleTodoMessageItem: { _, _, _ in }, displayTodoToggleUnavailable: { _ in }, openStarsPurchase: { _ in - }, openRankInfo: { _, _, _ in }, openSetPeerAvatar: {}, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) + }, openRankInfo: { _, _, _ in + }, openSetPeerAvatar: { + }, displayPollRestrictedToast: { _ in + }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.dimNode = ASDisplayNode() self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 2e9920d1fa..b960866103 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2581,7 +2581,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { openStarsPurchase: { _ in }, openRankInfo: { _, _, _ in - }, openSetPeerAvatar: { + }, + openSetPeerAvatar: { + }, + displayPollRestrictedToast: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), From 5b013073bf8af7c63b8b6661a2721167841bc574 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 16:50:01 +0200 Subject: [PATCH 45/69] Fix --- submodules/WebUI/Sources/WebAppWebView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index edf115e500..acebade7a2 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -291,8 +291,10 @@ final class WebAppWebView: WKWebView { } func sendEvent(name: String, data: String?) { - guard let trustedOrigin = self.trustedOrigin, self.origin == trustedOrigin else { - return + if self.useSecuredEventProxy { + guard let trustedOrigin = self.trustedOrigin, self.origin == trustedOrigin else { + return + } } let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))" self.evaluateJavaScript(script, completionHandler: { _, _ in From 9b8abdb5eec21dba628085e7409e0d1c56084958 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 30 Apr 2026 16:51:22 +0200 Subject: [PATCH 46/69] Fix --- submodules/WebUI/Sources/WebAppWebView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/submodules/WebUI/Sources/WebAppWebView.swift b/submodules/WebUI/Sources/WebAppWebView.swift index edf115e500..acebade7a2 100644 --- a/submodules/WebUI/Sources/WebAppWebView.swift +++ b/submodules/WebUI/Sources/WebAppWebView.swift @@ -291,8 +291,10 @@ final class WebAppWebView: WKWebView { } func sendEvent(name: String, data: String?) { - guard let trustedOrigin = self.trustedOrigin, self.origin == trustedOrigin else { - return + if self.useSecuredEventProxy { + guard let trustedOrigin = self.trustedOrigin, self.origin == trustedOrigin else { + return + } } let script = "window.TelegramGameProxy && window.TelegramGameProxy.receiveEvent && window.TelegramGameProxy.receiveEvent(\"\(name)\", \(data ?? "null"))" self.evaluateJavaScript(script, completionHandler: { _, _ in From 6758c94e33b71613cbf6106d3fdebd23ebea6b14 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 17:31:21 +0200 Subject: [PATCH 47/69] Reapply "Update tgcalls" This reverts commit 4e81b79254422134cf211a2d2de1198137ae9f52. --- submodules/TgVoipWebrtc/tgcalls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index a6ea40ebf1..43d03b0b82 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit a6ea40ebf18233fad5aae29aa3d30561f839a6a8 +Subproject commit 43d03b0b827be372fed73ad8a31405d5a74028a4 From 5962a563e4e2643750445fe1fc36bb866ad26be1 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Thu, 30 Apr 2026 18:28:43 +0200 Subject: [PATCH 48/69] feat: tgcalls CLI test tool with group SFU, video, and adaptation Squashed buildout of the tgcalls testbench: - CLI test tool with --mode p2p/reflector/group/group-churn, cross-version interop (--version, --version2), and quiet/summary output - Linux toolchain + Docker multi-stage build, AWS Fargate mass test harness, local parallel mass test harness with signaling loss simulation - SCTP writable gate, retransmission timer tuning, role-based handshake - InstanceV2CompatImpl (PeerConnection backend with V2Impl signaling) and SignalingTranslator for v14.0.0 interop - In-process Go/Pion SFU (ICE+DTLS+SRTP+SCTP per participant) with audio RTP forwarding, ActiveAudio/VideoSsrcs data channel broadcast, RTCP feedback path, and CGo c-archive integration - GroupInstanceReferenceImpl (PeerConnection group-call) and mixed-impl group mode (--reference-participants), with SDP munging for simulcast - H264 simulcast group video (FakeVideoTrackSource pattern generator, FakeVideoSink frame counting, --video flag, two-pass channel setup, reactive video setup from ActiveVideoSsrcs) - Group churn stress mode (--mode group-churn, --churn-cycles) - SFU stream-quality adaptation: BandwidthEstimator, LayerSelector state machine, RtxRingBuffer, simulcast SSRC rewrite - Transport-cc feedback generation, NetworkSimulator (delay/jitter/loss/ token-bucket bandwidth), --network-scenario step-down-up - CLAUDE.md updates throughout Co-Authored-By: Claude Opus 4.7 (1M context) --- .bazelrc | 40 +- CLAUDE.md | 210 ++- Dockerfile | 52 + MODULE.bazel | 27 +- MODULE.bazel.lock | 61 +- bazel-tgcalls-telegram | 1 + build-system/BUILD | 16 + .../2026-04-09-reflector-support-design.md | 63 - submodules/TgVoipWebrtc/BUILD | 110 ++ submodules/TgVoipWebrtc/CLAUDE.md | 292 ++++ submodules/TgVoipWebrtc/tgcalls | 2 +- submodules/ffmpeg/BUILD | 32 +- .../Sources/FFMpeg/build-ffmpeg-bazel.sh | 112 +- submodules/ffmpeg/Sources/FFMpeg/pkg-config | 54 +- third-party/boringssl/src/crypto/internal.h | 5 +- third-party/dav1d/BUILD | 37 +- third-party/dav1d/build-dav1d-bazel.sh | 47 +- third-party/libjxl/BUILD | 2 + third-party/libjxl/build-libjxl-bazel.sh | 19 +- third-party/libvpx/BUILD | 15 +- third-party/libvpx/build-libvpx-bazel.sh | 28 +- third-party/libyuv/BUILD | 4 +- third-party/mozjpeg/BUILD | 2 + third-party/mozjpeg/build-mozjpeg-bazel.sh | 16 + third-party/ogg/BUILD | 6 +- third-party/ogg/include/ogg/config_types.h | 15 + third-party/openh264/BUILD | 3 + third-party/opus/BUILD | 6 +- third-party/opus/build-opus-bazel.sh | 4 + third-party/opusfile/BUILD | 6 +- third-party/rnnoise/BUILD | 6 +- third-party/td/BUILD | 2 + third-party/td/build-td-bazel.sh | 4 + third-party/webp/BUILD | 2 + third-party/webp/build-webp-bazel.sh | 16 + third-party/webrtc/BUILD | 138 +- .../webrtc/absl/absl/base/attributes.h | 7 +- third-party/webrtc/webrtc | 2 +- third-party/yasm/BUILD | 16 +- tools/go_sfu/BUILD | 18 + tools/go_sfu/CLAUDE.md | 107 ++ tools/go_sfu/bandwidth.go | 475 +++++++ tools/go_sfu/bandwidth_test.go | 249 ++++ tools/go_sfu/go.mod | 27 + tools/go_sfu/go.sum | 44 + tools/go_sfu/mux.go | 239 ++++ tools/go_sfu/network_sim.go | 128 ++ tools/go_sfu/participant.go | 637 +++++++++ tools/go_sfu/sfu.go | 1240 +++++++++++++++++ tools/go_sfu/twcc.go | 262 ++++ tools/tgcalls_cli/BUILD | 37 + tools/tgcalls_cli/CLAUDE.md | 41 + tools/tgcalls_cli/fake_video_sink.h | 24 + tools/tgcalls_cli/fake_video_source.cpp | 149 ++ tools/tgcalls_cli/fake_video_source.h | 38 + tools/tgcalls_cli/group_churn_mode.cpp | 205 +++ tools/tgcalls_cli/group_churn_mode.h | 10 + tools/tgcalls_cli/group_mode.cpp | 173 +++ tools/tgcalls_cli/group_mode.h | 5 + tools/tgcalls_cli/group_participant.cpp | 442 ++++++ tools/tgcalls_cli/group_participant.h | 143 ++ tools/tgcalls_cli/main.cpp | 602 ++++++++ tools/tgcalls_cli/run-local-test.sh | 105 ++ tools/tgcalls_cli/run-test.sh | 249 ++++ tools/tgcalls_cli/run-until-crash.sh | 93 ++ 65 files changed, 6948 insertions(+), 274 deletions(-) create mode 100644 Dockerfile create mode 120000 bazel-tgcalls-telegram delete mode 100644 docs/superpowers/specs/2026-04-09-reflector-support-design.md create mode 100644 submodules/TgVoipWebrtc/CLAUDE.md mode change 100755 => 100644 third-party/libvpx/build-libvpx-bazel.sh create mode 100644 third-party/ogg/include/ogg/config_types.h create mode 100644 tools/go_sfu/BUILD create mode 100644 tools/go_sfu/CLAUDE.md create mode 100644 tools/go_sfu/bandwidth.go create mode 100644 tools/go_sfu/bandwidth_test.go create mode 100644 tools/go_sfu/go.mod create mode 100644 tools/go_sfu/go.sum create mode 100644 tools/go_sfu/mux.go create mode 100644 tools/go_sfu/network_sim.go create mode 100644 tools/go_sfu/participant.go create mode 100644 tools/go_sfu/sfu.go create mode 100644 tools/go_sfu/twcc.go create mode 100644 tools/tgcalls_cli/BUILD create mode 100644 tools/tgcalls_cli/CLAUDE.md create mode 100644 tools/tgcalls_cli/fake_video_sink.h create mode 100644 tools/tgcalls_cli/fake_video_source.cpp create mode 100644 tools/tgcalls_cli/fake_video_source.h create mode 100644 tools/tgcalls_cli/group_churn_mode.cpp create mode 100644 tools/tgcalls_cli/group_churn_mode.h create mode 100644 tools/tgcalls_cli/group_mode.cpp create mode 100644 tools/tgcalls_cli/group_mode.h create mode 100644 tools/tgcalls_cli/group_participant.cpp create mode 100644 tools/tgcalls_cli/group_participant.h create mode 100644 tools/tgcalls_cli/main.cpp create mode 100755 tools/tgcalls_cli/run-local-test.sh create mode 100755 tools/tgcalls_cli/run-test.sh create mode 100755 tools/tgcalls_cli/run-until-crash.sh diff --git a/.bazelrc b/.bazelrc index 1ce9174344..5b12464e70 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,32 +1,34 @@ build --action_env=ZERO_AR_DATE=1 -build --apple_platform_type=ios build --enable_platform_specific_config -build --apple_crosstool_top=@local_config_apple_cc//:toolchain -build --crosstool_top=@local_config_apple_cc//:toolchain -build --host_crosstool_top=@local_config_apple_cc//:toolchain -build --per_file_copt=".*\.m$","@-fno-objc-msgsend-selector-stubs" -build --per_file_copt=".*\.mm$","@-fno-objc-msgsend-selector-stubs" - -build --features=debug_prefix_map_pwd_is_dot -build --features=swift.cacheable_swiftmodules -build --features=swift.debug_prefix_map -build --features=swift.enable_vfsoverlays +# macOS-specific settings (auto-applied on macOS via --enable_platform_specific_config) +build:macos --apple_platform_type=ios +build:macos --apple_crosstool_top=@local_config_apple_cc//:toolchain +build:macos --crosstool_top=@local_config_apple_cc//:toolchain +build:macos --host_crosstool_top=@local_config_apple_cc//:toolchain +build:macos --per_file_copt=".*\.m$","@-fno-objc-msgsend-selector-stubs" +build:macos --per_file_copt=".*\.mm$","@-fno-objc-msgsend-selector-stubs" +build:macos --features=debug_prefix_map_pwd_is_dot +build:macos --features=swift.cacheable_swiftmodules +build:macos --features=swift.debug_prefix_map +build:macos --features=swift.enable_vfsoverlays build:dbg --features=swift.emit_swiftsourceinfo +# Linux-specific settings (auto-applied on Linux via --enable_platform_specific_config) +build:linux --action_env=CC +build:linux --action_env=CXX + build --strategy=Genrule=standalone build --spawn_strategy=standalone -build --strategy=SwiftCompile=worker +build:macos --strategy=SwiftCompile=worker -#common --registry=https://raw.githubusercontent.com/bazelbuild/bazel-central-registry/main/ - -# SourceKit BSP: Swift indexing features -common --features=swift.index_while_building -common --features=swift.use_global_index_store -common --features=swift.use_global_module_cache -common --features=oso_prefix_is_pwd +# SourceKit BSP: Swift indexing features (macOS only) +common:macos --features=swift.index_while_building +common:macos --features=swift.use_global_index_store +common:macos --features=swift.use_global_module_cache +common:macos --features=oso_prefix_is_pwd # SourceKit BSP: Index build config (used for background indexing) common:index_build --experimental_convenience_symlinks=ignore diff --git a/CLAUDE.md b/CLAUDE.md index 74412a468f..0df8fa773e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,204 @@ # CLAUDE.md -This file provides guidance to AI assistants when working with code in this repository. +This is a testbench repository for the tgcalls VoIP library (from Telegram). It contains the full Telegram iOS source tree as a build dependency, but the focus is on testing and debugging tgcalls. ## Build -The app is built using Bazel. -## Code Style Guidelines -- **Naming**: PascalCase for types, camelCase for variables/methods -- **Imports**: Group and sort imports at the top of files -- **Error Handling**: Properly handle errors with appropriate redaction of sensitive data -- **Formatting**: Use standard Swift/Objective-C formatting and spacing -- **Types**: Prefer strong typing and explicit type annotations where needed -- **Documentation**: Document public APIs with comments +Requires Bazel 8.4.2 (download to `build-input/` if not present): + +```bash +# One-time setup: create build configuration stub +mkdir -p build-input/configuration-repository/provisioning +# Then populate MODULE.bazel, BUILD, variables.bzl, provisioning/BUILD +# (see build-input/configuration-repository/ for existing stubs) + +# Build the CLI test tool +./build-input/bazel-8.4.2 build //tools/tgcalls_cli:tgcalls_cli +``` + +The system-installed Bazel (v9) is NOT compatible with this codebase. + +## Linux Build + +Prerequisites (Ubuntu/Debian): +```bash +apt install gcc g++ cmake meson ninja-build nasm make autoconf automake libtool pkg-config zlib1g-dev libbz2-dev +``` + +Download the Linux Bazel 8.4.2 binary to `build-input/`: +```bash +curl -fL "https://github.com/bazelbuild/bazel/releases/download/8.4.2/bazel-8.4.2-linux-arm64" -o build-input/bazel-8.4.2-linux +chmod +x build-input/bazel-8.4.2-linux +``` + +Build the CLI test tool: +```bash +./build-input/bazel-8.4.2-linux build //tools/tgcalls_cli:tgcalls_cli +``` + +The same Bazel 8.4.2 version is required. The build uses the system GCC toolchain and system-installed cmake/meson/ninja for third-party library compilation. + +## Docker Build + +Build a minimal Linux container image from macOS (or any Docker host): + +```bash +# Build (uses BuildKit cache — first build ~5 min, rebuilds seconds) +docker build -t tgcalls-test . + +# Run locally +docker run --rm tgcalls-test --mode p2p --duration 5 --quiet +docker run --rm tgcalls-test --mode reflector --reflector 91.108.13.2:598 --duration 10 --quiet + +# Push to ECR for AWS deployment +docker tag tgcalls-test 654654616143.dkr.ecr.eu-west-1.amazonaws.com/tgcalls-test:latest +docker push 654654616143.dkr.ecr.eu-west-1.amazonaws.com/tgcalls-test:latest +``` + +The Dockerfile uses a multi-stage build: full build environment in stage 1, minimal runtime image (~50MB) in stage 2. Bazel's build cache is preserved across `docker build` invocations via `--mount=type=cache`. The image is built for ARM64 (matches Apple Silicon and Fargate ARM). + +## Testing + +### Local Mass Testing + +Run large-scale P2P tests locally using `run-local-test.sh`. Launches N parallel processes, each running a single call, and aggregates results. + +```bash +# 1000 calls, 150 parallel, 30% loss (default settings) +./tools/tgcalls_cli/run-local-test.sh -n 1000 + +# Custom parallelism and duration +./tools/tgcalls_cli/run-local-test.sh -n 500 -j 100 -d 30 + +# Custom loss parameters +./tools/tgcalls_cli/run-local-test.sh -n 1000 --drop-rate 0.5 --delay 100-300 +``` + +Options: `-n NUM` (count), `-j PARALLEL` (default 150), `-d DURATION` (default 15s), `--drop-rate RATE` (default 0.3), `--delay MIN-MAX` (default 50-200), `--mode MODE` (default p2p), `--version VER` (default 13.0.0). + +Typical results: 100% success rate at 30% loss on Apple Silicon (16 cores). + +### AWS Mass Testing + +Run large-scale reflector tests on ECS Fargate (ARM64). Infrastructure is pre-configured in eu-west-1. Requires Docker push first. + +```bash +# Launch 1000 tasks across all Telegram reflectors, 30s each +./tools/tgcalls_cli/run-test.sh -n 1000 -d 30 + +# Collect results +./tools/tgcalls_cli/run-test.sh --results +``` + +The script fetches the reflector list from `https://core.telegram.org/getReflectorList`, embeds the IPs as a `--reflector-list` argument (each task picks a random IP + random port 596-599), and launches in waves of 500 (Fargate concurrent task limit). Results are collected from CloudWatch Logs with automatic retry for delayed log delivery. + +**AWS resources** (eu-west-1, account 654654616143): +- ECR: `tgcalls-test` +- ECS cluster: `tgcalls-test` +- Task definition: `tgcalls-test` (ARM64 Fargate, 0.25 vCPU, 512MB) +- CloudWatch log group: `/ecs/tgcalls-test` +- Subnets: `subnet-0292f49f3b4885428`, `subnet-09b8edab6eb20b837`, `subnet-0f464b5c62c9a6d1a` +- Security group: `sg-0d87a1f19be76c160` + +**Cost**: ~$0.01 per 100 tasks (~$0.10 per 1000-task run). + +## tgcalls CLI Test Tool + +Located at `tools/tgcalls_cli/`. Runs tgcalls instances in-process with emulated signaling and validates audio/media flow. + +```bash +# P2P mode (direct loopback, no network) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 10 + +# Reflector mode (routes through a real Telegram UDP reflector) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode reflector --reflector 91.108.13.2:596 --duration 10 + +# Random reflector from a list (picks one at random, randomizes port 596-599 if no port given) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --reflector-list "91.108.13.2,91.108.13.3,91.108.9.1" --duration 10 + +# Simulate lossy signaling (30% drop, 50-200ms random delay) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 30 --drop-rate 0.3 --delay 50-200 + +# Quiet mode (summary only, full tgcalls logs dumped on failure) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 5 --quiet + +# Group mode (in-process SFU with N participants) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 3 --duration 10 + +# Mixed group mode (CustomImpl + ReferenceImpl participants) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --reference-participants 2 --duration 15 + +# Group mode with video (H264 simulcast, pattern generator) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 15 + +# Mixed group with video (both CustomImpl and ReferenceImpl send/receive video) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --reference-participants 2 --video --duration 15 + +# ReferenceImpl-only video +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 0 --reference-participants 3 --video --duration 15 + +# Group churn stress test (100 join/leave cycles, then validate base group) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 3 --duration 10 + +# Group churn with video +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 3 --video --churn-cycles 100 --duration 10 + +# Mixed implementations churn +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 2 --reference-participants 1 --video --duration 10 +``` + +`--mode` is required (`p2p`, `reflector`, `group`, or `group-churn`) unless `--reflector-list` is used (implies reflector mode). Exit code 0 = success. Exit code 1 = failure. + +For p2p/reflector: success = call established, stats logs non-empty, BWE non-zero for both sides. +For group (audio): success = all N participants report `isConnected = true` AND all participants receive remote audio (non-zero SSRC with level > 0.05 via `audioLevelsUpdated`). Remote 440Hz sine tone typically arrives at ~0.126 level. +For group (video): audio criteria plus every participant receives ≥1 decoded video frame from every other participant via `FakeVideoSink` frame counting. +For group-churn: success = all churn cycles complete without crash/hang AND base group passes group validation (all connected, all receiving audio, and if video, all receiving video from all other base participants). + +### CLI Options +- `--mode p2p|reflector|group|group-churn` — call mode (required unless `--reflector-list` used) +- `--reflector host:port` — single reflector address +- `--reflector-list addr,addr,...` — comma-separated list, one picked at random +- `--version VER` — caller tgcalls protocol version (default: `13.0.0`) +- `--version2 VER` — callee tgcalls protocol version (default: same as `--version`). Enables cross-version interop testing. +- `--participants N` — number of CustomImpl participants in group mode (default: 3) +- `--reference-participants N` — number of ReferenceImpl (PeerConnection-based) participants in group mode (default: 0). Total = `--participants` + `--reference-participants`. +- `--duration N` — test duration in seconds (default: 10) +- `--drop-rate 0.0-1.0` — signaling packet drop probability +- `--delay min-max` — signaling delay range in ms (e.g., `50-200`) +- `--video` — enable H264 video with simulcast in group mode (both CustomImpl and ReferenceImpl participants) +- `--churn-cycles N` — number of join/leave cycles in group-churn mode (default: 100) +- `--network-scenario NAME` — network simulation test scenario (e.g., `step-down-up`). Group mode only. +- `--quiet` — summary output only + +### Modes +- **P2P**: Direct loopback, `enableP2P=true`, no servers configured +- **Reflector**: Routes through a Telegram UDP reflector, `enableP2P=false`, configures `RtcServer` with `login="reflector"` and random peer tags (16 bytes, byte 0 = `0x00` for caller, `0x01` for callee) +- **Group**: In-process SFU with N participants using `GroupInstanceCustomImpl` and/or `GroupInstanceReferenceImpl`. The SFU is implemented in Go using Pion's low-level ICE/DTLS/SRTP/SCTP APIs (not PeerConnection), linked into the same process via CGo c-archive. Each participant gets a full ICE + DTLS + SRTP + SCTP transport stack over localhost UDP. Audio RTP is selectively forwarded between all participants. With `--video`, H264 video with 3-layer simulcast is enabled. Mixed-implementation groups (CustomImpl + ReferenceImpl) are supported via `--reference-participants`. +- **Group Churn**: Stress test for participant join/leave dynamics. Creates a base group of N participants, then rapidly cycles an additional participant in and out `--churn-cycles` times (default 100). After churn, validates that the base group is healthy: all connected, all receiving audio, and if `--video` is enabled, all receiving video. Alternates between CustomImpl and ReferenceImpl for the cycling participant. The `--duration` controls the stabilization wait after churn completes. ## Project Structure -- Core launch and application extensions code is in `Telegram/` directory -- Most code is organized into libraries in `submodules/` -- External code is located in `third-party/` -- No tests are used at the moment \ No newline at end of file + +- `tools/tgcalls_cli/` — CLI test tool (main.cpp, group_mode.cpp, group_participant.h/.cpp, group_churn_mode.h/.cpp, fake_video_source.h/.cpp, fake_video_sink.h, run-test.sh, run-local-test.sh, BUILD) +- `tools/go_sfu/` — Go/Pion SFU library (sfu.go, participant.go, mux.go, go.mod/go.sum), built as c-archive via rules_go + Gazelle, linked into tgcalls_cli +- `submodules/TgVoipWebrtc/tgcalls/tgcalls/` — tgcalls library source +- `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/` — group call implementations (GroupInstanceCustomImpl, GroupInstanceReferenceImpl, GroupNetworkManager, GroupJoinPayloadInternal) +- `submodules/TgVoipWebrtc/tgcalls/tgcalls/v2/` — v2 implementation (InstanceV2Impl, InstanceV2ReferenceImpl, InstanceV2CompatImpl, NativeNetworkingImpl, SignalingSctpConnection, SignalingTranslator) +- `submodules/TgVoipWebrtc/BUILD` — contains `tgcalls_core` target (C++ only, macOS-native) and `TgVoipWebrtc` target (iOS, ObjC) +- `third-party/webrtc/` — WebRTC source and BUILD +- `third-party/webrtc/webrtc/net/dcsctp/` — dc-sctp (SCTP implementation) +- `third-party/webrtc/webrtc/media/sctp/dcsctp_transport.cc` — WebRTC SCTP wrapper +- `third-party/` — other dependencies (opus, libvpx, ffmpeg, boringssl, etc.) +- `docs/superpowers/specs/` — design specs +- `docs/superpowers/plans/` — implementation plans + +## Code Style +- **Naming**: PascalCase for types, camelCase for variables/methods +- **Language**: C++17 for tgcalls code +- **Formatting**: Standard C++ formatting + +## Further Context + +When working in these areas, additional `CLAUDE.md` files load automatically: +- `tools/tgcalls_cli/CLAUDE.md` — CLI test tool architecture (P2P/Reflector, Group), supported version matrix +- `tools/go_sfu/CLAUDE.md` — Go SFU internals: build integration, bandwidth adaptation, transport-cc feedback, network simulation +- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals: macOS/Linux build patches, SCTP signaling, InstanceV2CompatImpl, GroupInstanceCustomImpl/ReferenceImpl, video pitfalls, known issues diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..ef8d208be5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 +# Multi-stage build for tgcalls_cli Linux container +# Build: docker build -t tgcalls-test . +# Run: docker run tgcalls-test --mode reflector --reflector 91.108.13.2:598 --duration 10 + +# ============================================================ +# Stage 1: Build +# ============================================================ +FROM ubuntu:24.04 AS builder + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ cmake meson ninja-build nasm make \ + autoconf automake libtool pkg-config python3 \ + unzip curl ca-certificates patch \ + zlib1g-dev libbz2-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src + +# Copy source tree +COPY . . + +# Always download Bazel for the container's architecture (host copy may be wrong arch) +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then BAZEL_ARCH="x86_64"; \ + elif [ "$ARCH" = "aarch64" ]; then BAZEL_ARCH="arm64"; \ + else echo "Unsupported arch: $ARCH" && exit 1; fi && \ + curl -fL "https://github.com/bazelbuild/bazel/releases/download/8.4.2/bazel-8.4.2-linux-${BAZEL_ARCH}" \ + -o build-input/bazel-8.4.2-linux && \ + chmod +x build-input/bazel-8.4.2-linux + +# Build with persistent Bazel cache +RUN --mount=type=cache,target=/root/.cache/bazel \ + ./build-input/bazel-8.4.2-linux build //tools/tgcalls_cli:tgcalls_cli \ + --strategy=Genrule=standalone --spawn_strategy=standalone && \ + cp bazel-bin/tools/tgcalls_cli/tgcalls_cli /tmp/tgcalls_cli + +# ============================================================ +# Stage 2: Runtime (minimal) +# ============================================================ +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /tmp/tgcalls_cli /usr/local/bin/tgcalls_cli + +ENTRYPOINT ["tgcalls_cli"] +CMD ["--help"] diff --git a/MODULE.bazel b/MODULE.bazel index 99b68b09ef..a81f6bc28f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -3,6 +3,25 @@ http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_ bazel_dep(name = "bazel_features", version = "1.33.0") bazel_dep(name = "bazel_skylib", version = "1.8.1") bazel_dep(name = "platforms", version = "0.0.11") +bazel_dep(name = "rules_go", version = "0.60.0", repo_name = "io_bazel_rules_go") + +go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk") +go_sdk.download(version = "1.24.2") + +bazel_dep(name = "gazelle", version = "0.43.0", repo_name = "bazel_gazelle") + +go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps") +go_deps.from_file(go_mod = "//tools/go_sfu:go.mod") +use_repo( + go_deps, + "com_github_pion_datachannel", + "com_github_pion_dtls_v3", + "com_github_pion_ice_v4", + "com_github_pion_logging", + "com_github_pion_rtcp", + "com_github_pion_sctp", + "com_github_pion_srtp_v3", +) bazel_dep(name = "rules_xcodeproj") local_path_override( @@ -30,26 +49,26 @@ local_path_override( http_file( name = "cmake_tar_gz", - urls = ["https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-macos-universal.tar.gz"], sha256 = "3be85f5b999e327b1ac7d804cbc9acd767059e9f603c42ec2765f6ab68fbd367", + urls = ["https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-macos-universal.tar.gz"], ) http_file( name = "meson_tar_gz", - urls = ["https://github.com/mesonbuild/meson/releases/download/1.6.0/meson-1.6.0.tar.gz"], sha256 = "999b65f21c03541cf11365489c1fad22e2418bb0c3d50ca61139f2eec09d5496", + urls = ["https://github.com/mesonbuild/meson/releases/download/1.6.0/meson-1.6.0.tar.gz"], ) http_file( name = "ninja-mac_zip", - urls = ["https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-mac.zip"], sha256 = "89a287444b5b3e98f88a945afa50ce937b8ffd1dcc59c555ad9b1baf855298c9", + urls = ["https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-mac.zip"], ) http_file( name = "flatbuffers_zip", - urls = ["https://github.com/google/flatbuffers/archive/refs/tags/v24.12.23.zip"], sha256 = "c5cd6a605ff20350c7faa19d8eeb599df6117ea4aabd16ac58a7eb5ba82df4e7", + urls = ["https://github.com/google/flatbuffers/archive/refs/tags/v24.12.23.zip"], ) provisioning_profile_repository = use_extension("@build_bazel_rules_apple//apple:apple.bzl", "provisioning_profile_repository_extension") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1a563a1a8e..f4d57a995e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -13,6 +13,7 @@ "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/source.json": "d725d73707d01bb46ab3ca59ba408b8e9bd336642ca77a2269d4bfb8bbfd413d", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", @@ -25,7 +26,8 @@ "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", - "https://bcr.bazel.build/modules/bazel_features/1.33.0/source.json": "13617db3930328c2cd2807a0f13d52ca870ac05f96db9668655113265147b2a6", + "https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3", + "https://bcr.bazel.build/modules/bazel_features/1.36.0/source.json": "279625cafa5b63cc0a8ee8448d93bc5ac1431f6000c50414051173fd22a6df3c", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", @@ -43,6 +45,12 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0", + "https://bcr.bazel.build/modules/gazelle/0.43.0/MODULE.bazel": "846e1fe396eefc0f9ddad2b33e9bd364dd993fc2f42a88e31590fe0b0eefa3f0", + "https://bcr.bazel.build/modules/gazelle/0.43.0/source.json": "021a77f6625906d9d176e2fa351175e842622a5d45989312f2ad4924aab72df6", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", @@ -58,13 +66,14 @@ "https://bcr.bazel.build/modules/nlohmann_json/3.12.0.bcr.1/source.json": "93f82a5ae985eb935c539bfee95e04767187818189241ac956f3ccadbdb8fb02", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", - "https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29", "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", @@ -73,6 +82,8 @@ "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", @@ -97,11 +108,18 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8", "https://bcr.bazel.build/modules/rules_cc/0.2.15/source.json": "197965c6dcca5c98a9288f93849e2e1c69d622e71b0be8deb524e22d48c88e32", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", + "https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0", + "https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb", + "https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", @@ -138,6 +156,7 @@ "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", @@ -168,6 +187,7 @@ "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" @@ -278,43 +298,6 @@ ] ] } - }, - "@@rules_xcodeproj+//xcodeproj:extensions.bzl%internal": { - "general": { - "bzlTransitiveDigest": "+qmLBZzimJ0CYyKoQg6/pbdkTnu/s4e5IisoM+TLM+8=", - "usagesDigest": "fvsnMonVwKDYnBfww4bXuYie3WU0d9VSqT2gePSdQco=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, - "generatedRepoSpecs": { - "rules_xcodeproj_generated": { - "repoRuleId": "@@rules_xcodeproj+//xcodeproj:repositories.bzl%generated_files_repo", - "attributes": {} - } - }, - "recordedRepoMappingEntries": [ - [ - "bazel_features+", - "bazel_features_globals", - "bazel_features++version_extension+bazel_features_globals" - ], - [ - "bazel_features+", - "bazel_features_version", - "bazel_features++version_extension+bazel_features_version" - ], - [ - "rules_xcodeproj+", - "bazel_features", - "bazel_features+" - ], - [ - "rules_xcodeproj+", - "bazel_tools", - "bazel_tools" - ] - ] - } } } } diff --git a/bazel-tgcalls-telegram b/bazel-tgcalls-telegram new file mode 120000 index 0000000000..527907b756 --- /dev/null +++ b/bazel-tgcalls-telegram @@ -0,0 +1 @@ +/private/var/tmp/_bazel_ali/c2a220fda8d2ffb82200b23e08e81f60/execroot/_main \ No newline at end of file diff --git a/build-system/BUILD b/build-system/BUILD index 5fb4a881f4..43d4c19eab 100644 --- a/build-system/BUILD +++ b/build-system/BUILD @@ -4,6 +4,22 @@ config_setting( values = {"cpu": "ios_sim_arm64"}, ) +config_setting( + name = "linux_arm64", + constraint_values = [ + "@platforms//os:linux", + "@platforms//cpu:aarch64", + ], +) + +config_setting( + name = "linux_x86_64", + constraint_values = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], +) + exports_files([ "GenerateStrings/GenerateStrings.py", ]) diff --git a/docs/superpowers/specs/2026-04-09-reflector-support-design.md b/docs/superpowers/specs/2026-04-09-reflector-support-design.md deleted file mode 100644 index e66959982d..0000000000 --- a/docs/superpowers/specs/2026-04-09-reflector-support-design.md +++ /dev/null @@ -1,63 +0,0 @@ -# tgcalls CLI: UDP Reflector Mode - -## Overview - -Add a `--mode reflector` option to the tgcalls CLI test tool, routing both call instances through a real Telegram UDP reflector instead of direct P2P loopback. - -## CLI Interface - -- `--mode p2p` — current behavior (direct P2P loopback, no servers) -- `--mode reflector` — route through a real Telegram UDP reflector -- `--mode` is **required**. Exit with usage error if missing. -- `--reflector host:port` — specifies the reflector address. **Required** when `--mode reflector`. Error if missing in reflector mode or if provided with `--mode p2p`. -- `--duration` and `--quiet` are unchanged. - -## Reflector Configuration - -When `--mode reflector`: - -### Peer Tag Generation - -Generate 16 random bytes. Copy to make two tags: -- Caller tag: byte 0 = `0x00` -- Callee tag: byte 0 = `0x01` - -### RtcServer Setup - -Each instance gets one `RtcServer` entry with its respective peer tag: - -| Field | Value | -|------------|------------------------------------------------| -| `id` | `1` | -| `host` | from `--reflector` argument | -| `port` | from `--reflector` argument | -| `login` | `"reflector"` | -| `password` | hex-encoded 16-byte peer tag (differs by side) | -| `isTurn` | `true` | -| `isTcp` | `false` | - -### Descriptor Changes - -- `config.enableP2P = false` -- `rtcServers` populated with the single reflector server -- No `customParameters` changes (standalone reflector mode is not used) - -### P2P Mode - -Unchanged from current behavior: `enableP2P = true`, empty `rtcServers`. - -## Summary Output - -Add a mode line to the call summary: - -``` -Mode: reflector (91.108.13.2:596) -``` - -or: - -``` -Mode: p2p -``` - -No other output changes. The existing audio validation (440Hz sine, non-silence detection) remains the success criterion for both modes. diff --git a/submodules/TgVoipWebrtc/BUILD b/submodules/TgVoipWebrtc/BUILD index 14cc452523..c5e0acf5d0 100644 --- a/submodules/TgVoipWebrtc/BUILD +++ b/submodules/TgVoipWebrtc/BUILD @@ -7,6 +7,11 @@ config_setting( }, ) +config_setting( + name = "is_macos", + constraint_values = ["@platforms//os:macos"], +) + optimization_flags = select({ ":debug_build": [ ], @@ -107,6 +112,7 @@ sources = glob([ "tgcalls/tgcalls/group/AudioStreamingPartPersistentDecoder.cpp", "tgcalls/tgcalls/group/AVIOContextImpl.cpp", "tgcalls/tgcalls/group/GroupInstanceCustomImpl.cpp", + "tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp", "tgcalls/tgcalls/group/GroupJoinPayloadInternal.cpp", "tgcalls/tgcalls/group/GroupNetworkManager.cpp", "tgcalls/tgcalls/group/StreamingMediaContext.cpp", @@ -116,6 +122,7 @@ sources = glob([ "tgcalls/tgcalls/v2/DirectNetworkingImpl.cpp", "tgcalls/tgcalls/v2/ExternalSignalingConnection.cpp", "tgcalls/tgcalls/v2/InstanceV2Impl.cpp", + "tgcalls/tgcalls/v2/InstanceV2CompatImpl.cpp", "tgcalls/tgcalls/v2/InstanceV2ReferenceImpl.cpp", "tgcalls/tgcalls/v2/NativeNetworkingImpl.cpp", "tgcalls/tgcalls/v2/RawTcpSocket.cpp", @@ -125,6 +132,7 @@ sources = glob([ "tgcalls/tgcalls/v2/SignalingConnection.cpp", "tgcalls/tgcalls/v2/SignalingEncryption.cpp", "tgcalls/tgcalls/v2/SignalingSctpConnection.cpp", + "tgcalls/tgcalls/v2/CustomDcSctpSocket.cpp", ] objc_library( @@ -189,3 +197,105 @@ objc_library( "//visibility:public", ], ) + +# Pure C++ core library for non-iOS targets (e.g. CLI tools). +# Uses FakeInterface instead of darwin platform files. +cc_library( + name = "tgcalls_core", + srcs = glob([ + "tgcalls/tgcalls/**/*.h", + "tgcalls/tgcalls/**/*.hpp", + "tgcalls/tgcalls/platform/fake/**/*.cpp", + "tgcalls/tgcalls/third-party/**/*.cpp", + "tgcalls/tgcalls/utils/**/*.cpp", + ], allow_empty=True, exclude = [ + "tgcalls/tgcalls/legacy/**", + "tgcalls/tgcalls/platform/tdesktop/**", + "tgcalls/tgcalls/platform/android/**", + "tgcalls/tgcalls/platform/windows/**", + "tgcalls/tgcalls/platform/uwp/**", + "tgcalls/tgcalls/platform/darwin/**", + "tgcalls/tgcalls/desktop_capturer/**", + ]) + [ + "tgcalls/tgcalls/Manager.cpp", + "tgcalls/tgcalls/MediaManager.cpp", + "tgcalls/tgcalls/AudioDeviceHelper.cpp", + "tgcalls/tgcalls/ChannelManager.cpp", + "tgcalls/tgcalls/CodecSelectHelper.cpp", + "tgcalls/tgcalls/CryptoHelper.cpp", + "tgcalls/tgcalls/EncryptedConnection.cpp", + "tgcalls/tgcalls/FakeAudioDeviceModule.cpp", + "tgcalls/tgcalls/FakeVideoTrackSource.cpp", + "tgcalls/tgcalls/FieldTrialsConfig.cpp", + "tgcalls/tgcalls/Instance.cpp", + "tgcalls/tgcalls/InstanceImpl.cpp", + "tgcalls/tgcalls/LogSinkImpl.cpp", + "tgcalls/tgcalls/Message.cpp", + "tgcalls/tgcalls/NetworkManager.cpp", + "tgcalls/tgcalls/SctpDataChannelProviderInterfaceImpl.cpp", + "tgcalls/tgcalls/StaticThreads.cpp", + "tgcalls/tgcalls/ThreadLocalObject.cpp", + "tgcalls/tgcalls/TurnCustomizerImpl.cpp", + "tgcalls/tgcalls/VideoCaptureInterface.cpp", + "tgcalls/tgcalls/VideoCaptureInterfaceImpl.cpp", + + "tgcalls/tgcalls/group/AudioStreamingPart.cpp", + "tgcalls/tgcalls/group/AudioStreamingPartInternal.cpp", + "tgcalls/tgcalls/group/AudioStreamingPartPersistentDecoder.cpp", + "tgcalls/tgcalls/group/AVIOContextImpl.cpp", + "tgcalls/tgcalls/group/GroupInstanceCustomImpl.cpp", + "tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp", + "tgcalls/tgcalls/group/GroupJoinPayloadInternal.cpp", + "tgcalls/tgcalls/group/GroupNetworkManager.cpp", + "tgcalls/tgcalls/group/StreamingMediaContext.cpp", + "tgcalls/tgcalls/group/VideoStreamingPart.cpp", + + "tgcalls/tgcalls/v2/ContentNegotiation.cpp", + "tgcalls/tgcalls/v2/DirectNetworkingImpl.cpp", + "tgcalls/tgcalls/v2/ExternalSignalingConnection.cpp", + "tgcalls/tgcalls/v2/InstanceV2Impl.cpp", + "tgcalls/tgcalls/v2/InstanceV2CompatImpl.cpp", + "tgcalls/tgcalls/v2/InstanceV2ReferenceImpl.cpp", + "tgcalls/tgcalls/v2/NativeNetworkingImpl.cpp", + "tgcalls/tgcalls/v2/RawTcpSocket.cpp", + "tgcalls/tgcalls/v2/ReflectorPort.cpp", + "tgcalls/tgcalls/v2/ReflectorRelayPortFactory.cpp", + "tgcalls/tgcalls/v2/Signaling.cpp", + "tgcalls/tgcalls/v2/SignalingConnection.cpp", + "tgcalls/tgcalls/v2/SignalingEncryption.cpp", + "tgcalls/tgcalls/v2/SignalingSctpConnection.cpp", + "tgcalls/tgcalls/v2/SignalingTranslator.cpp", + "tgcalls/tgcalls/v2/CustomDcSctpSocket.cpp", + ], + copts = [ + "-I{}/tgcalls/tgcalls".format(package_name()), + "-Ithird-party/webrtc/webrtc", + "-Ithird-party/webrtc/dependencies", + "-Ithird-party/webrtc/absl", + "-Ithird-party/libyuv", + "-DRTC_ENABLE_VP9", + "-DTGVOIP_NAMESPACE=tgvoip_webrtc", + "-std=c++17", + "-w", + ] + select({ + "@platforms//os:linux": ["-DWEBRTC_LINUX", "-DWEBRTC_POSIX"], + "//conditions:default": ["-DWEBRTC_MAC", "-DWEBRTC_POSIX"], + }) + optimization_flags, + deps = [ + "//third-party/webrtc", + "//third-party/boringssl:crypto", + "//third-party/boringssl:ssl", + "//third-party/ogg:ogg", + "//third-party/opusfile:opusfile", + "//submodules/ffmpeg:ffmpeg", + "//third-party/rnnoise:rnnoise", + "//third-party/libyuv", + ] + select({ + ":is_macos": ["//third-party/webrtc:webrtc_platform_helpers"], + "//conditions:default": [], + }), + linkopts = ["-lz"], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TgVoipWebrtc/CLAUDE.md b/submodules/TgVoipWebrtc/CLAUDE.md new file mode 100644 index 0000000000..7766eb1d60 --- /dev/null +++ b/submodules/TgVoipWebrtc/CLAUDE.md @@ -0,0 +1,292 @@ +# tgcalls Library + +The tgcalls VoIP library source. See the root `CLAUDE.md` for build instructions and the project overview. + +## macOS Build Support + +This repo has been patched to support native macOS arm64 builds (`darwin_arm64` CPU) in addition to the original iOS targets. Changes made: +- `third-party/webrtc/BUILD` — added `@platforms//os:linux` to `arch_specific_cflags` select (fixes macOS getting Linux flags via `//conditions:default`); moved `cocoa_threading.mm` from `cc_library` to `webrtc_platform_helpers` `objc_library` (Bazel 8 rejects `.mm` in `cc_library`); replaced UIKit with AppKit for macOS +- `third-party/openh264/BUILD` — added `//conditions:default` to `select()` statements +- `third-party/webrtc/absl/absl/base/attributes.h` — disabled `ABSL_ATTRIBUTE_LIFETIME_BOUND` (newer Xcode clang rejects it on void-returning functions) +- 8 third-party BUILD files + 8 build shell scripts — added `darwin_arm64 -> macos_arm64` architecture support (opus, libvpx, ffmpeg, dav1d, mozjpeg, webp, libjxl, td) + +## Linux Build Support + +The repo supports native Linux arm64 and x86_64 builds. Key changes from the iOS/macOS-only baseline: +- `.bazelrc` — Apple toolchain settings under `build:macos`, Linux uses default CC toolchain via `build:linux` (auto-selected by `--enable_platform_specific_config`) +- `build-system/BUILD` — `linux_arm64` and `linux_x86_64` config_settings +- `objc_library` → `cc_library` conversions for pure C/C++ targets (ogg, opusfile, rnnoise, opus, libvpx, dav1d, ffmpeg wrappers, WebRTC main target) +- WebRTC BUILD — platform flags via `select()` (`-DWEBRTC_LINUX` vs `-DWEBRTC_MAC`), stdlib task queue instead of GCD on Linux, macOS-only sources excluded +- Third-party genrule build scripts — Linux architecture cases added (libvpx, dav1d, ffmpeg), system cmake/meson/ninja used instead of downloaded macOS binaries +- BoringSSL — `_Generic` C11 guarded for C++ mode (GCC compatibility) +- tgcalls headers — `#include ` added for GCC 15 strictness + +## SCTP Signaling + +### Writable Gate (role-based handshake ordering) + +tgcalls uses a custom SCTP association (via dc-sctp) over the signaling channel for reliable message delivery. `SignalingSctpConnection` wraps `DcSctpTransport` with a `SignalingPacketTransport` shim. + +The SCTP handshake is ordered using DcSctpTransport's writable gate (`MaybeConnectSocket()`), mirroring how WebRTC PeerConnection uses DTLS writable state to control SCTP connection timing: + +- **Caller** (`isOutgoing=true`): `SignalingPacketTransport` starts writable → `Connect()` fires immediately → sends INIT +- **Callee** (`isOutgoing=false`): starts not-writable → `Connect()` deferred → on first `receiveExternal()`, `setWritable(true)` fires `SignalWritableState` → `MaybeConnectSocket()` → `Connect()` + +The callee's `Connect()` and processing of the caller's INIT happen synchronously within the same `BlockingCall` on the network thread (RFC 4960 §5.2.1 simultaneous-open). + +Key files: +- `SignalingSctpConnection.cpp` — `SignalingPacketTransport` writable state, `setWritable()`, constructor takes `isInitiator` +- `InstanceV2Impl.cpp` / `InstanceV2ReferenceImpl.cpp` — pass `_encryptionKey.isOutgoing` as `isInitiator` +- `third-party/webrtc/webrtc/media/sctp/dcsctp_transport.cc:662-667` — `MaybeConnectSocket()` gate (unmodified) + +### Timer Tuning (CustomDcSctpSocket) + +WebRTC's stock `DcSctpSocket` has a bug: `max_timer_backoff_duration` is wired to the T3-rtx (data retransmission) timer but **not** to the t1_init and t1_cookie (handshake) timers. The handshake timers use unlimited exponential backoff (1000, 2000, 4000, 8000ms...), causing the SCTP handshake to stall for 20+ seconds under packet loss with simultaneous-open (both sides call `Connect()`). + +Fix: `CustomDcSctpSocket` (in `tgcalls/v2/`) is a copy of `DcSctpSocket` with the 6-line fix that passes `max_timer_backoff_duration` to the t1_init and t1_cookie timer constructors. A `CustomDcSctpSocketFactory` in `SignalingSctpConnection.cpp` creates it instead of the stock socket, with configurable timer overrides. WebRTC source is **untouched**. + +Default signaling SCTP timer values (set in `SignalingSctpConnection::Options`): + +| Setting | WebRTC Default | Signaling Override | +|---|---|---| +| `t1_init_timeout` | 1000ms | 400ms | +| `t1_cookie_timeout` | 1000ms | 400ms | +| `max_timer_backoff_duration` | 3000ms | 750ms | +| `max_init_retransmits` | 8 | unlimited (from `DcSctpTransport::Start`) | + +Retry pattern: 400ms, 750ms, 750ms, 750ms... (~18 attempts in 15s). At 30% loss, 100% success rate over 5000 runs. + +These values are configurable via JSON custom parameters (passed to `InstanceV2Impl` via `config.customParameters`): +- `network_sctp_t1_init_ms` — T1-init timeout (0 = use default 400ms) +- `network_sctp_t1_cookie_ms` — T1-cookie timeout (0 = use default 400ms) +- `network_sctp_max_backoff_ms` — max timer backoff cap (0 = use default 750ms) + +Key files: +- `tgcalls/v2/CustomDcSctpSocket.h/.cpp` — patched `DcSctpSocket` copy +- `tgcalls/v2/SignalingSctpConnection.cpp` — `CustomDcSctpSocketFactory`, timer option plumbing +- `tgcalls/v2/InstanceV2Impl.cpp` — reads JSON params, passes `Options` to `SignalingSctpConnection` + +## InstanceV2CompatImpl (version 14.0.0) + +A cross-version interop implementation that uses WebRTC PeerConnection internally (like InstanceV2ReferenceImpl) but speaks V2Impl's signaling protocol (`InitialSetupMessage`, `NegotiateChannelsMessage`, `CandidatesMessage`). This enables bidirectional calls between PeerConnection-based clients and V2Impl-based clients (versions 7.0.0–13.0.0). + +### Architecture + +``` +PeerConnection <-> SignalingTranslator <-> EncryptedConnection <-> SignalingSctpConnection +``` + +- **SignalingTranslator** (`tgcalls/v2/SignalingTranslator.h/.cpp`): Converts between `cricket::SessionDescription` (PeerConnection's internal format) and V2Impl signaling messages. Uses `JsepSessionDescription` programmatic API — no SDP string round-trips. +- **Outbound**: PeerConnection generates offer/answer → SignalingTranslator extracts `InitialSetupMessage` (transport params) + `NegotiateChannelsMessage` (media contents) +- **Inbound**: Buffers both messages until complete → builds `cricket::SessionDescription` → wraps in `JsepSessionDescription` → `SetRemoteDescription` + +### Key Design Decisions + +- **No data channel with V2Impl peers**: WebRTC data channel requires PeerConnection on both sides. V2Impl uses NativeNetworkingImpl (no PeerConnection). When paired with V2Impl, the data channel m-line is padded as `rejected` in the remote answer so PeerConnection accepts it. For CompatImpl↔CompatImpl calls, the data channel works normally. +- **Caller-only renegotiation**: Only the outgoing side triggers offers from `onRenegotiationNeeded` to prevent unsolicited offer storms. +- **MediaState via signaling**: `MediaStateMessage` sent over the SCTP signaling channel (not data channel), ensuring it works with both V2Impl and CompatImpl peers. +- **Sequential content IDs**: Uses "0", "1", ... as m-line mids, matching PeerConnection's default scheme. +- **Shared conversion functions**: `convertContentInfoToSignalingContent()` and `convertSignalingContentToContentInfo()` extracted to `Signaling.h/.cpp` for use by both `ContentNegotiationContext` (V2Impl) and `SignalingTranslator` (CompatImpl). + +### Cross-Version Testing + +```bash +# CompatImpl caller → V2Impl callee +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --version 14.0.0 --version2 13.0.0 --duration 10 --quiet + +# V2Impl caller → CompatImpl callee +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --version 13.0.0 --version2 14.0.0 --duration 10 --quiet + +# With lossy signaling +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --version 14.0.0 --version2 13.0.0 --duration 15 --drop-rate 0.3 --delay 50-200 --quiet +``` + +100% success rate at 30% loss in both directions (tested with 50 sequential + 20 parallel runs each direction). + +Key files: +- `tgcalls/v2/InstanceV2CompatImpl.h/.cpp` — main implementation +- `tgcalls/v2/SignalingTranslator.h/.cpp` — cricket↔signaling conversion +- `tgcalls/v2/Signaling.h/.cpp` — shared conversion functions (`convertContentInfoToSignalingContent`, `convertSignalingContentToContentInfo`) + +## GroupInstanceCustomImpl (Group Calls) + +The group call implementation in `tgcalls/group/GroupInstanceCustomImpl.cpp` (~4700 lines). Uses a client-server model with an SFU, unlike 1:1 calls which are peer-to-peer. + +### Protocol Stack +- **Join signaling**: JSON over application layer (`emitJoinPayload` → app sends to SFU → `setJoinResponsePayload`) +- **Transport**: ICE + DTLS-SRTP over UDP (standard WebRTC transport, NOT PeerConnection) +- **Media**: RTP/RTCP with Opus audio (48kHz, 2ch, 32kbps), optional VP8/H264/VP9 video +- **Control**: SCTP data channel over DTLS for Colibri protocol (video constraints, debug messages) + +### Join Flow +1. Client calls `emitJoinPayload()` → generates JSON with audio SSRC, ICE ufrag/pwd, DTLS fingerprint +2. Application sends JSON to SFU server +3. Server responds with its ICE candidates, DTLS fingerprint, video codec info +4. Client calls `setJoinResponsePayload(json)` → ICE/DTLS negotiation begins +5. On connection: `networkStateUpdated` callback fires + +### Participant Discovery +- Unknown SSRC arrives in RTP → `receiveUnknownSsrcPacket()` → `maybeRequestUnknownSsrc(ssrc)` +- App's `requestMediaChannelDescriptions` callback queries server for SSRC→participant mapping +- `addIncomingAudioChannel(ssrc, userId)` creates decoder channel + +### Colibri Data Channel Messages +```json +// SFU → Client +{"colibriClass": "SenderVideoConstraints", "videoConstraints": {"idealHeight": 360}} + +// Client → SFU +{"colibriClass": "ReceiverVideoConstraints", "defaultConstraints": {"maxHeight": 0}, + "constraints": {"endpoint1": {"minHeight": 720, "maxHeight": 720}}} +``` + +### Key Files +- `tgcalls/group/GroupInstanceCustomImpl.h/.cpp` — main implementation +- `tgcalls/group/GroupNetworkManager.h/.cpp` — ICE/DTLS/SRTP transport +- `tgcalls/group/GroupJoinPayloadInternal.h/.cpp` — join JSON serialization + +## GroupInstanceReferenceImpl (PeerConnection-based Group Calls) + +An alternative group call implementation that uses standard WebRTC PeerConnection instead of the manual ICE/DTLS/SRTP management in `GroupInstanceCustomImpl`. Supports both audio and video (H264 simulcast). Implements the same `GroupInstanceInterface`. + +### Architecture + +``` +GroupInstanceReferenceImpl + └── PeerConnection (single, to SFU) + ├── sendrecv audio transceiver (outgoing audio) + ├── sendonly video transceiver (outgoing H264 simulcast, SDP-munged SSRCs) + ├── recvonly audio transceivers (one per remote SSRC, added dynamically) + ├── recvonly video transceivers (one per remote endpoint, added dynamically) + └── data channel ("data", for ActiveAudioSsrcs / ActiveVideoSsrcs) +``` + +### How It Differs from CustomImpl + +| Aspect | CustomImpl | ReferenceImpl | +|--------|-----------|---------------| +| Transport | Manual ICE/DTLS/SRTP via GroupNetworkManager | WebRTC PeerConnection | +| SDP | None (custom JSON protocol) | Local SDP construction, translates to/from JSON | +| SSRC discovery | `unknownSsrcPacketReceived` on raw RTP | `ActiveAudioSsrcs`/`ActiveVideoSsrcs` data channel messages from SFU | +| Audio channels | Manual `IncomingAudioChannel` per SSRC | PeerConnection recvonly transceivers | +| Audio levels | RTP header extension parsing | Synthetic levels based on known SSRCs | +| Video outgoing | Manual `cricket::VideoChannel` with direct SSRC control | PeerConnection sendonly transceiver + SDP munging for simulcast SSRCs | +| Video incoming | Manual `IncomingVideoChannel` per endpoint | PeerConnection recvonly transceivers with SSRCs in answer | +| Video decode | Manual decoder lifecycle | PeerConnection handles internally | +| Code size | ~4700 lines | ~1500 lines | + +### Join Flow (SDP Translation) + +1. Create PeerConnection with Opus audio transceiver, sendonly video transceiver (no track), and data channel +2. `createOffer` → munge video SSRCs (replace PeerConnection's auto-generated SSRCs with pre-allocated simulcast SSRCs) → `SetLocalDescription` → extract ICE/DTLS params from local SDP +3. Serialize as JSON (same format as CustomImpl): `{ssrc, ufrag, pwd, fingerprints, ssrc-groups}` +4. Parse SFU response JSON → construct `JsepSessionDescription("answer")` programmatically via `cricket::SessionDescription` API (no SDP string parsing) +5. `SetRemoteDescription` → ICE/DTLS connects via PeerConnection internals +6. Add remote ICE candidates via `AddIceCandidate` after `SetRemoteDescription` +7. Activate outgoing video: attach `FakeVideoTrackSource` track to the existing sendonly transceiver via `sender()->SetTrack()` — no renegotiation needed + +### Dynamic Participant Handling + +**Audio:** +1. SFU sends `{"colibriClass":"ActiveAudioSsrcs","ssrcs":[54321,98765]}` over data channel +2. Client diffs against known SSRCs +3. New SSRCs: add recvonly audio transceiver → renegotiate (new offer + constructed answer mirroring offer mids) +4. Removed SSRCs: clean up from tracking map + +**Video:** +1. SFU sends `ActiveVideoSsrcs` over data channel → forwarded to app via `dataChannelMessageReceived` +2. App calls `setRequestedVideoChannels()` → adds recvonly video transceivers, sends `ReceiverVideoConstraints` over data channel +3. Renegotiate: new offer → munge outgoing video SSRCs → `SetLocalDescription` → build answer with incoming video SSRCs → `SetRemoteDescription` +4. `wirePendingVideoSinks()`: attach `FakeVideoSink` to the recvonly transceiver's receiver track after `SetRemoteDescription` completes +5. Renegotiations are serialized (`_isRenegotiating` / `_pendingRenegotiation` flags) to prevent overlapping offer/answer cycles + +### Outgoing Video: SDP Munging for Simulcast + +PeerConnection's API doesn't support SSRC-based simulcast directly (only RID-based, which doesn't put SSRCs in the SDP). The workaround: + +1. Pre-allocate 6 random video SSRCs at construction: 3 layers × (primary + RTX) +2. Add a sendonly video transceiver in `start()` with no track +3. Before `SetLocalDescription`, `mungeVideoSsrcsInOffer()` replaces the video m-line's auto-generated `StreamParams` with our pre-allocated SSRCs + SIM + FID groups +4. `UpdateLocalStreams_w()` in WebRTC's `channel.cc` sees SSRCs already present and skips generation +5. Later, `setVideoSource()` just calls `sender()->SetTrack()` — no renegotiation + +### Incoming Video: SSRC-Based Demux + +The answer for incoming video m-lines includes remote SSRCs from `VideoChannelDescription.ssrcGroups`. This is required because CustomImpl sets the `WebRTC-Video-DiscardPacketsWithUnknownSsrc` field trial process-wide, which disables unsignaled stream creation. Without explicit SSRCs, PeerConnection drops incoming video packets in mixed groups. + +### Key Implementation Details + +- **ICE roles**: PeerConnection uses standard ICE (full agent, controlling when remote is ICE-lite). The SFU uses `Accept` for PeerConnection clients vs `Dial` for CustomImpl clients. +- **Loopback**: `PeerConnectionFactory::Options::network_ignore_mask = 0` enables loopback interface gathering for localhost SFU +- **MID exclusion**: The `buildRemoteAnswer()` excludes the `urn:ietf:params:rtp-hdrext:sdes:mid` RTP header extension from ALL m-lines (audio and video). The SFU forwards raw RTP with the sender's MID value, which would cause the BUNDLE demuxer to route packets to the wrong channel. Without MID, PeerConnection falls back to SSRC/PT-based routing. +- **RTP header extensions**: Copied from the local offer per m-line (minus MID), ensuring BUNDLE-safe IDs. Hardcoding IDs risks collisions across the BUNDLE group. +- **SDP mid matching**: During renegotiation, the constructed remote answer mirrors the local offer's m-line structure and mids exactly. Mismatched mids cause `SetRemoteDescription` to fail. +- **Audio level reporting**: Uses synthetic levels (0.1) for all known remote SSRCs, since the SFU forwards RTP with extension IDs that may not match PeerConnection's negotiated mapping +- **Video sink wiring**: `OnTrack` doesn't fire for locally-created recvonly transceivers. Sinks are wired explicitly in `wirePendingVideoSinks()` after `SetRemoteDescription` completes, and also in `addIncomingVideoOutput()` if the track already exists. +- **H264 codec in answer**: PT 104 (primary) + PT 105 (RTX, apt=104), matching WebRTC's `assignPayloadTypes` order. RTCP feedback: nack, nack pli, ccm fir, goog-remb, transport-cc. +- **Renegotiation serialization**: Only one offer/answer cycle runs at a time. Deferred renegotiations only fire if there are unnegotiated transceivers (no mid assigned yet), avoiding redundant cycles. + +### Key Files +- `tgcalls/group/GroupInstanceReferenceImpl.h/.cpp` — implementation +- `tgcalls/group/GroupInstanceImpl.h` — shared `GroupInstanceInterface` + +## Video Support Pitfalls + +Critical findings from implementing video in the test SFU — relevant for anyone working on group video: + +### H264 Decoder Requires Two Build Flags +The WebRTC BUILD needs BOTH `-DWEBRTC_USE_H264` (encoder, OpenH264) AND `-DWEBRTC_USE_H264_DECODER` (decoder, FFmpeg). Without the decoder flag, `H264Decoder::Create()` returns nullptr and WebRTC silently falls back to `NullVideoDecoder` which accepts frames but never decodes them — no error logged. The encoder works fine without the decoder flag, making this easy to miss. + +### FFmpeg 7+ Removed `reordered_opaque` +`h264_decoder_impl.cc` uses `AVCodecContext::reordered_opaque` and `AVFrame::reordered_opaque` for passing timestamps through the decode pipeline. FFmpeg 7+ removed this field. The fix uses `AVPacket::pts` instead. IMPORTANT: `AVCodecContext::opaque` is already used to store the `H264DecoderImpl*` pointer (line 74 of `AVGetBuffer2`) — do NOT use it for timestamps. + +### Outgoing Video Channel Steals Incoming RTP +`GroupInstanceCustomImpl` creates separate `cricket::VideoChannel` objects for outgoing and incoming video, all sharing the same `RtpTransport`. The outgoing channel's `WebRtcVideoReceiveChannel` has an "unsignalled SSRC" handler that creates default receive streams for unknown SSRCs. When video RTP from other participants arrives before `IncomingVideoChannel` registers its SSRCs, the outgoing channel intercepts the packets permanently. Fix: enable the `WebRTC-Video-DiscardPacketsWithUnknownSsrc` field trial in the field trial string. + +### Video Channel Setup Is Reactive, Not Pre-Registered +Video channels are set up reactively when `ActiveVideoSsrcs` arrives via the data channel — same as the real Telegram app. The `dataChannelMessageReceived` callback in `GroupInstanceDescriptor` forwards Colibri messages to the app, which calls `setRequestedVideoChannels`. The `DiscardPacketsWithUnknownSsrc` field trial prevents the outgoing channel from stealing RTP packets for SSRCs not yet registered. The SFU sends proactive PLI after constraints arrive, ensuring keyframes are produced after the incoming channel is ready. + +### SFU Must Send Proactive PLI +WebRTC's `VideoReceiveStream2` doesn't immediately request a keyframe when a new receive stream is created — it waits until it detects missing packets or a timeout. The SFU must proactively send PLI to the sender when a receiver first requests video via `ReceiverVideoConstraints`. Without this, the decoder waits indefinitely for a keyframe. + +### RTP/RTCP Demux: Marker Bit False Positives +RFC 5761 demux by second byte: RTCP types are 200-211. But RTP with Marker=1 and dynamic PT ≥ 96 gives byte[1] ≥ 224. Using `byte[1] >= 200` falsely classifies H264 RTP (PT=104, M=1 → byte[1]=232) as RTCP. Correct range: `byte[1] >= 200 && byte[1] < 224`. + +### SRTCP Requires Separate Contexts from SRTP +Pion's `SessionSRTP` and `SessionSRTCP` can't share the same `net.Conn` (both start read loops that fight for packets). The solution: demux RTCP at the transport level (in `PacketDemux`), create separate `srtp.Context` instances for SRTCP decrypt/encrypt using the same DTLS-extracted keys, and handle RTCP manually without `SessionSRTCP`. + +### PeerConnection Simulcast SSRCs Require SDP Munging +PeerConnection's API doesn't support SSRC-based simulcast (only RID-based). With RID-based simulcast, SSRCs are NOT in the `createOffer` SDP — they're generated internally during `SetLocalDescription` and not accessible via `sender->GetParameters()` (only primary SSRCs, not RTX). The workaround: add a single-encoding transceiver (no RIDs), then replace the auto-generated `StreamParams` in the offer with pre-allocated SSRCs + SIM + FID groups before calling `SetLocalDescription`. `UpdateLocalStreams_w()` skips generation when SSRCs already exist. IMPORTANT: `transceiver->mid()` is `nullopt` before `SetLocalDescription` — match by content direction, not mid. + +### MID RTP Header Extension Causes Wrong Channel Routing in SFU +The SFU forwards raw RTP packets including all header extensions. If the sender's video RTP includes a MID extension (e.g., MID="1"), the receiver's PeerConnection BUNDLE demuxer routes the packet to its own mid=1 channel — which is the outgoing video, not the incoming video transceiver. Fix: exclude `urn:ietf:params:rtp-hdrext:sdes:mid` from ALL m-lines in `buildRemoteAnswer()`. Without MID negotiated, PeerConnection falls back to SSRC/PT-based routing. This must be done for ALL m-lines (including audio) because the BUNDLE transport shares the extension map across all channels. + +### `DiscardPacketsWithUnknownSsrc` Is Process-Wide +CustomImpl calls `field_trial::InitFieldTrialsFromString(...)` which sets `WebRTC-Video-DiscardPacketsWithUnknownSsrc/Enabled/` globally for the process. In mixed groups, this prevents ReferenceImpl's PeerConnection from creating unsignaled receive streams for incoming video. Fix: include explicit remote video SSRCs in the `buildRemoteAnswer()` for incoming video m-lines, so PeerConnection registers SSRC-based demux entries instead of relying on unsignaled stream handling. + +### `OnTrack` Doesn't Fire for Locally-Created Recvonly Transceivers +When you call `AddTransceiver(MEDIA_TYPE_VIDEO, {direction=recvonly})`, PeerConnection creates the transceiver and its receiver track immediately. `OnTrack` only fires when a REMOTE-initiated track is added. For locally-created recvonly transceivers, you must wire sinks explicitly after `SetRemoteDescription` completes — don't wait for `OnTrack`. + +### SSRC Parsing: json11 int_value() Overflows for uint32 > INT_MAX +`GoSfu_QueryVideoSsrcs` returns SSRCs as `uint32` in JSON. For values > 2^31, json11's `int_value()` (which returns `int`) overflows to `INT_MAX` (2147483647). Fix: use `number_value()` (returns `double`) and cast via `int64_t` to `uint32_t`. + +### Join Payload JSON Field Name: `"sources"` Not `"ssrcs"` +tgcalls serializes video SSRC groups in `GroupJoinInternalPayload::serialize()` using the key `"sources"` (not `"ssrcs"`). The Go SFU's JSON struct tags must match: `Sources []int32 \`json:"sources"\``. + +### Simulcast Max Layers Depends on Source Resolution, Not Bitrate +WebRTC's `kSimulcastFormats` table in `video/config/simulcast.cc` hardcodes `max_layers` per resolution: 640x360 → 2 layers, 960x540 → 3 layers, 1280x720 → 3 layers. The `SimulcastEncoderAdapter` uses this to cap the number of encoders regardless of available bitrate. If you need 3 simulcast layers, the source must be at least 960x540. The `FakeVideoTrackSource` uses 1280x720 for this reason. With 1280x720 and scale factors /4, /2, /1, the layers are 320x180, 640x360, 1280x720. + +### SFU Must Rewrite SSRCs When Switching Simulcast Layers +CustomImpl's `IncomingVideoChannel` calls `SetSink(_mainVideoSsrc, ...)` where `_mainVideoSsrc` is the first SSRC in the SIM group (layer 0). The video sink only receives decoded frames from that specific SSRC's receive stream. When the SFU forwards a higher layer's packets, it must rewrite bytes 8-11 of the RTP header to the primary (layer 0) SSRC. RTX packets must similarly be rewritten to the layer 0 FID SSRC. Without this, higher-layer packets are delivered to the wrong receive stream and produce zero decoded frames. This is standard SFU behavior for simulcast — Jitsi and mediasoup do the same. + +### Sender BWE Start Bitrate Determines Initial Layer Count +`adjustBitratePreferences` sets `start_bitrate_bps = max(min_bitrate_bps, 400k)`. At 400kbps start, the `BitrateAllocator` gives L0 (60k) + L1 (110k) = 170k, leaving only 230k for L2 which needs min 300k. Layer 2 is disabled until the GCC ramps up. The SFU's transport-cc feedback enables this ramp-up. The `UpdateAllocationLimits` log shows `total_requested_max_bitrate` — if this is below the sum of all layers' min bitrates, some layers are excluded. + +### `assignPayloadTypes` Codec Ordering +WebRTC's `assignPayloadTypes` assigns dynamic PTs starting at 100 in order: VP8 (100/101), VP9 (102/103), H264 (104/105). Both sender and receiver call this independently with the same codec list, so PTs match. The SFU's join response codec PTs (100 for H264 in our case) are used by `configureVideoParams` to SELECT which codec to use, but the actual PT assignment comes from `assignPayloadTypes`. + +## Known Issues +- `ThreadLocalObject::~ThreadLocalObject()` posts fire-and-forget cleanup tasks to the tgcalls media thread. If the process does orderly static destruction, the static thread pool may be torn down while these tasks are still executing, causing "pure virtual function called". The CLI tool uses `_exit()` to avoid this. This is not a problem in the real Telegram app. +- `SignalingSctpConnection::OnReadyToSend()` had a missing `break` after the first send failure in its pending-data flush loop. This could cause application-level message reordering (though the application handles it gracefully via `_pendingIceCandidates` buffering). Fixed in our fork. +- `InstanceV2ReferenceImpl::writeStateLogRecords()` had a use-after-free: it captured a raw `Call*` pointer on the media thread and posted it to the worker thread. If `stop()` called `_peerConnection->Close()` (which destroys `Call`) between the post and worker thread execution, the worker thread would dereference a dangling pointer. The `call_ptr_` field in WebRTC's `PeerConnection` is `Call* const` and is never nulled, so the existing null check didn't catch this. Fixed with an `_isStopped` atomic flag checked in the worker thread lambda before accessing `call`. Manifested as ~2% segfault rate under 250-process parallel load; 100% pass rate after fix (5000/5000). +- WebRTC's `RTC_LOG` writes to stdout, not stderr. There is no way to separate it from application output within a single process. The local mass test harness (`run-local-test.sh`) works around this by using separate processes and checking exit codes rather than parsing output. diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index 8099768559..eac45e8d51 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit 8099768559edb0efd2d1b300090c18141226e9a8 +Subproject commit eac45e8d5154ff52d0c706ab23f8b430925f7f6f diff --git a/submodules/ffmpeg/BUILD b/submodules/ffmpeg/BUILD index 2c1758dee2..9dad938d82 100644 --- a/submodules/ffmpeg/BUILD +++ b/submodules/ffmpeg/BUILD @@ -271,6 +271,13 @@ genrule( elif [ "$(TARGET_CPU)" == "ios_sim_arm64" ]; then BUILD_ARCH="sim_arm64" VARIANT="debug" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" + VARIANT="debug" + elif [ "$(TARGET_CPU)" == "k8" ] || [ "$(TARGET_CPU)" == "x86_64" ]; then + BUILD_ARCH="linux_x86_64" + elif [ "$(TARGET_CPU)" == "aarch64" ] || [ "$(TARGET_CPU)" == "arm64" ]; then + BUILD_ARCH="linux_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi @@ -301,24 +308,21 @@ cc_library( ] ) -objc_library( +cc_library( name = "ffmpeg", - module_name = "ffmpeg", - enable_modules = True, hdrs = ["Public/third_party/ffmpeg/" + x for x in ffmpeg_header_paths] + ["Public/" + x for x in ffmpeg_header_paths], includes = [ "Public", ], - sdk_dylibs = [ - "libbz2", - "libiconv", - "z", - ], - sdk_frameworks = [ - "AudioToolbox", - "CoreAudio", - "VideoToolbox" - ], + linkopts = select({ + "@platforms//os:linux": ["-lbz2", "-lz"], + "//conditions:default": [ + "-lbz2", "-liconv", "-lz", + "-framework AudioToolbox", + "-framework CoreAudio", + "-framework VideoToolbox", + ], + }), deps = [ ":ffmpeg_lib", "//third-party/libvpx:vpx", @@ -327,5 +331,5 @@ objc_library( ], visibility = [ "//visibility:public", - ] + ], ) diff --git a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh index a816a5ac02..5015e7489a 100755 --- a/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh +++ b/submodules/ffmpeg/Sources/FFMpeg/build-ffmpeg-bazel.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash set -x @@ -7,7 +7,7 @@ ARCHS="" for RAW_ARCH in $RAW_ARCHS; do ARCH_NAME="$RAW_ARCH" - if [ "$ARCH_NAME" = "i386" -o "$ARCH_NAME" = "x86_64" -o "$ARCH_NAME" = "arm64" -o "$ARCH_NAME" = "armv7" -o "$ARCH_NAME" = "sim_arm64" ] + if [ "$ARCH_NAME" = "i386" -o "$ARCH_NAME" = "x86_64" -o "$ARCH_NAME" = "arm64" -o "$ARCH_NAME" = "armv7" -o "$ARCH_NAME" = "sim_arm64" -o "$ARCH_NAME" = "macos_arm64" -o "$ARCH_NAME" = "linux_arm64" -o "$ARCH_NAME" = "linux_x86_64" ] then ARCHS="$ARCHS $ARCH_NAME" else @@ -38,24 +38,21 @@ LIB_NAMES="libavcodec libavformat libavutil libswresample" set -e CONFIGURE_FLAGS="--enable-cross-compile --disable-programs \ - --disable-armv5te --disable-armv6 --disable-armv6t2 \ + --disable-armv5te --disable-armv6 --disable-armv6t2 \ --disable-doc --enable-pic --disable-all --disable-everything \ --enable-avcodec \ --enable-swresample \ --enable-avformat \ --disable-xlib \ --enable-libopus \ - --enable-libvpx \ - --enable-libdav1d \ - --enable-audiotoolbox \ + --enable-libvpx \ + --enable-libdav1d \ --enable-bsf=aac_adtstoasc,vp9_superframe,h264_mp4toannexb \ - --enable-decoder=h264,libvpx_vp9,hevc,libopus,flac,alac_at,pcm_s16le,pcm_s24le,pcm_f32le,gsm_ms_at,libdav1d,av1,mp3,aac_at \ - --enable-encoder=libvpx_vp9,aac_at \ + --enable-decoder=h264,libvpx_vp9,hevc,libopus,flac,pcm_s16le,pcm_s24le,pcm_f32le,libdav1d,av1,mp3 \ --enable-demuxer=aac,mov,m4v,mp3,ogg,libopus,flac,wav,aiff,matroska,mpegts, \ --enable-parser=aac,h264,mp3,libopus \ --enable-protocol=file \ --enable-muxer=mp4,matroska,ogg,mpegts \ - --enable-hwaccel=h264_videotoolbox,hevc_videotoolbox,av1_videotoolbox \ " #vorbis @@ -84,7 +81,7 @@ do do LIB="$THIN/$ARCH/lib/$LIB_NAME.a" if [ -f "$LIB" ]; then - LIB_DATE=`crc32 "$LIB"` + LIB_DATE=$(crc32 "$LIB" 2>/dev/null || cksum "$LIB" 2>/dev/null | cut -d' ' -f1 || echo "unknown") LIBS_HASH="$LIBS_HASH $ARCH/$LIB:$LIB_DATE" fi done @@ -99,7 +96,11 @@ then echo "PATH=$PATH" echo "pkg-config=$(which pkg-config)" fi - if [ ! `which "$GAS_PREPROCESSOR_PATH"` ]; then + IS_LINUX=false + for A in $ARCHS; do + case "$A" in linux_*) IS_LINUX=true ;; esac + done + if [ "$IS_LINUX" = "false" ] && [ ! `which "$GAS_PREPROCESSOR_PATH"` ]; then echo '$GAS_PREPROCESSOR_PATH not found.' exit 1 fi @@ -114,6 +115,8 @@ then ARCH="$RAW_ARCH" if [ "$RAW_ARCH" == "sim_arm64" ]; then ARCH="arm64" + elif [ "$RAW_ARCH" == "macos_arm64" ]; then + ARCH="arm64" fi echo "building $RAW_ARCH..." @@ -124,10 +127,26 @@ then LIBVPX_PATH="$SOURCE_DIR/libvpx" LIBDAV1D_PATH="$SOURCE_DIR/libdav1d" + if [ "$RAW_ARCH" = "linux_arm64" ]; then + ARCH="aarch64" + CFLAGS="$EXTRA_CFLAGS -fPIC" + CC="gcc" + AS="gcc" + PLATFORM="linux" + elif [ "$RAW_ARCH" = "linux_x86_64" ]; then + ARCH="x86_64" + CFLAGS="$EXTRA_CFLAGS -fPIC" + CC="gcc" + AS="nasm" + PLATFORM="linux" + else CFLAGS="$EXTRA_CFLAGS -arch $ARCH" if [ "$RAW_ARCH" = "sim_arm64" ]; then PLATFORM="iPhoneSimulator" CFLAGS="$CFLAGS -mios-simulator-version-min=$DEPLOYMENT_TARGET --target=arm64-apple-ios$DEPLOYMENT_TARGET-simulator" + elif [ "$RAW_ARCH" = "macos_arm64" ]; then + PLATFORM="MacOSX" + CFLAGS="$CFLAGS -mmacosx-version-min=14.0 --target=arm64-apple-macosx14.0" else PLATFORM="iPhoneOS" CFLAGS="$CFLAGS -mios-version-min=$DEPLOYMENT_TARGET" @@ -136,15 +155,18 @@ then EXPORT="GASPP_FIX_XCODE5=1" fi fi + fi - XCRUN_SDK=`echo $PLATFORM | tr '[:upper:]' '[:lower:]'` - CC="xcrun -sdk $XCRUN_SDK clang" + if [ "$PLATFORM" != "linux" ]; then + XCRUN_SDK=`echo $PLATFORM | tr '[:upper:]' '[:lower:]'` + CC="xcrun -sdk $XCRUN_SDK clang" - if [ "$RAW_ARCH" = "arm64" ] || [ "$RAW_ARCH" = "sim_arm64" ] - then - AS="$GAS_PREPROCESSOR_PATH -arch aarch64 -- $CC" - else - AS="$GAS_PREPROCESSOR_PATH -- $CC" + if [ "$RAW_ARCH" = "arm64" ] || [ "$RAW_ARCH" = "sim_arm64" ] || [ "$RAW_ARCH" = "macos_arm64" ] + then + AS="$GAS_PREPROCESSOR_PATH -arch aarch64 -- $CC" + else + AS="$GAS_PREPROCESSOR_PATH -- $CC" + fi fi CXXFLAGS="$CFLAGS" @@ -161,22 +183,42 @@ then echo "1" >/dev/null else mkdir -p "$THIN/$RAW_ARCH" - TMPDIR=${TMPDIR/%\/} "$SOURCE/configure" \ - --target-os=darwin \ - --arch=$ARCH \ - --cc="$CC" \ - --as="$AS" \ - $CONFIGURE_FLAGS \ - --extra-cflags="$CFLAGS" \ - --extra-ldflags="$LDFLAGS" \ - --prefix="$THIN/$RAW_ARCH" \ - --pkg-config="$PKG_CONFIG" \ - --pkg-config-flags="--libopus_path $LIBOPUS_PATH --libvpx_path $LIBVPX_PATH --libdav1d_path $LIBDAV1D_PATH" \ - || exit 1 + if [ "$PLATFORM" = "linux" ]; then + TMPDIR=${TMPDIR/%\/} "$SOURCE/configure" \ + --target-os=linux \ + --arch=$ARCH \ + --cc="$CC" \ + --as="$AS" \ + $CONFIGURE_FLAGS \ + --enable-encoder=libvpx_vp9 \ + --extra-cflags="$CFLAGS" \ + --extra-ldflags="$LDFLAGS" \ + --prefix="$THIN/$RAW_ARCH" \ + --pkg-config="$PKG_CONFIG" \ + --pkg-config-flags="--libopus_path $LIBOPUS_PATH --libvpx_path $LIBVPX_PATH --libdav1d_path $LIBDAV1D_PATH" \ + || exit 1 + else + TMPDIR=${TMPDIR/%\/} "$SOURCE/configure" \ + --target-os=darwin \ + --arch=$ARCH \ + --cc="$CC" \ + --as="$AS" \ + $CONFIGURE_FLAGS \ + --enable-audiotoolbox \ + --enable-decoder=alac_at,gsm_ms_at,aac_at \ + --enable-encoder=libvpx_vp9,aac_at \ + --enable-hwaccel=h264_videotoolbox,hevc_videotoolbox,av1_videotoolbox \ + --extra-cflags="$CFLAGS" \ + --extra-ldflags="$LDFLAGS" \ + --prefix="$THIN/$RAW_ARCH" \ + --pkg-config="$PKG_CONFIG" \ + --pkg-config-flags="--libopus_path $LIBOPUS_PATH --libvpx_path $LIBVPX_PATH --libdav1d_path $LIBDAV1D_PATH" \ + || exit 1 + fi echo "$CONFIGURE_FLAGS" > "$CONFIGURED_MARKER" fi - CORE_COUNT=`PATH="$PATH:/usr/sbin" sysctl -n hw.logicalcpu` + CORE_COUNT=$(nproc 2>/dev/null || PATH="$PATH:/usr/sbin" sysctl -n hw.logicalcpu 2>/dev/null || echo 4) make -j$CORE_COUNT install $EXPORT || exit 1 popd @@ -190,7 +232,7 @@ do do LIB="$THIN/$ARCH/lib/$LIB_NAME.a" if [ -f "$LIB" ]; then - LIB_DATE=`crc32 "$LIB"` + LIB_DATE=$(crc32 "$LIB" 2>/dev/null || cksum "$LIB" 2>/dev/null | cut -d' ' -f1 || echo "unknown") UPDATED_LIBS_HASH="$UPDATED_LIBS_HASH $ARCH/$LIB:$LIB_DATE" fi done @@ -215,7 +257,11 @@ then LIB_NAME="$(basename $LIB)" echo "LIPO_INPUT command find \"$THIN\" -name \"$LIB_NAME\"" LIPO_INPUT=`find "$THIN" -name "$LIB_NAME"` - lipo -create $LIPO_INPUT -output "$FAT/lib/$LIB_NAME" || exit 1 + if command -v lipo >/dev/null 2>&1; then + lipo -create $LIPO_INPUT -output "$FAT/lib/$LIB_NAME" || exit 1 + else + cp $LIPO_INPUT "$FAT/lib/$LIB_NAME" || exit 1 + fi done cp -rf "$THIN/$1/include" "$FAT" diff --git a/submodules/ffmpeg/Sources/FFMpeg/pkg-config b/submodules/ffmpeg/Sources/FFMpeg/pkg-config index 48e6014229..7a40f50389 100755 --- a/submodules/ffmpeg/Sources/FFMpeg/pkg-config +++ b/submodules/ffmpeg/Sources/FFMpeg/pkg-config @@ -1,4 +1,10 @@ -#!/bin/sh +#!/bin/bash + +# Strip version qualifiers (e.g. "dav1d >= 0.5.0" -> "dav1d") +# FFmpeg's configure passes these to pkg-config +strip_version() { + echo "$@" | sed 's/ *[><=!].*//' +} if [ "$1" == "--version" ]; then echo "0.29.2" @@ -7,8 +13,10 @@ elif [ "$1" == "--exists" ]; then NAME="$2" PRINT_ERRORS="0" if [ "$NAME" == "--print-errors" ]; then - NAME="$3" + NAME=$(strip_version "$3") PRINT_ERRORS="1" + else + NAME=$(strip_version "$2") fi if [ "$NAME" == "zlib" ]; then exit 0 @@ -87,17 +95,53 @@ elif [ "$1" == "--libs" ]; then echo "-lz" exit 0 elif [ "$NAME" == "opus" ]; then - echo "-L$LIBOPUS_PATH/lib -lopus" + echo "-L$LIBOPUS_PATH/lib -lopus -lm" exit 0 elif [ "$NAME" == "vpx" ]; then - echo "-L$LIBVPX_PATH/lib -lVPX" + echo "-L$LIBVPX_PATH/lib -lVPX -lm -lpthread" exit 0 elif [ "$NAME" == "dav1d" ]; then - echo "-L$LIBDAV1D_PATH/lib -ldav1d" + echo "-L$LIBDAV1D_PATH/lib -ldav1d -lm -lpthread -ldl" exit 0 else exit 1 fi +elif [[ "$1" == --variable=* ]]; then + # Handle --variable=includedir etc. + LIBOPUS_PATH="" + LIBVPX_PATH="" + LIBDAV1D_PATH="" + # Parse the library path flags to find NAME (last arg) + ARGS=("$@") + NAME="${ARGS[-1]}" + for ((i=1; i<${#ARGS[@]}-1; i++)); do + if [ "${ARGS[$i]}" == "--libopus_path" ]; then + LIBOPUS_PATH="${ARGS[$((i+1))]}" + elif [ "${ARGS[$i]}" == "--libvpx_path" ]; then + LIBVPX_PATH="${ARGS[$((i+1))]}" + elif [ "${ARGS[$i]}" == "--libdav1d_path" ]; then + LIBDAV1D_PATH="${ARGS[$((i+1))]}" + fi + done + VAR="${1#--variable=}" + if [ "$VAR" == "includedir" ]; then + if [ "$NAME" == "opus" ]; then + echo "$LIBOPUS_PATH/include" + exit 0 + elif [ "$NAME" == "vpx" ]; then + echo "$LIBVPX_PATH/include" + exit 0 + elif [ "$NAME" == "dav1d" ]; then + echo "$LIBDAV1D_PATH/include" + exit 0 + elif [ "$NAME" == "zlib" ]; then + echo "/usr/include" + exit 0 + fi + fi + # Return empty string for unhandled variables (non-fatal) + echo "" + exit 0 else exit 1 fi diff --git a/third-party/boringssl/src/crypto/internal.h b/third-party/boringssl/src/crypto/internal.h index 8e7fc86de1..d1f0b3ed2b 100644 --- a/third-party/boringssl/src/crypto/internal.h +++ b/third-party/boringssl/src/crypto/internal.h @@ -1170,7 +1170,8 @@ static inline uint64_t CRYPTO_rotr_u64(uint64_t value, int shift) { // CRYPTO_addc_* returns |x + y + carry|, and sets |*out_carry| to the carry // bit. |carry| must be zero or one. -#if OPENSSL_HAS_BUILTIN(__builtin_addc) +// _Generic is C11-only and not available in C++ mode with GCC. +#if OPENSSL_HAS_BUILTIN(__builtin_addc) && !defined(__cplusplus) #define CRYPTO_GENERIC_ADDC(x, y, carry, out_carry) \ (_Generic((x), \ @@ -1222,7 +1223,7 @@ static inline uint64_t CRYPTO_addc_u64(uint64_t x, uint64_t y, uint64_t carry, // CRYPTO_subc_* returns |x - y - borrow|, and sets |*out_borrow| to the borrow // bit. |borrow| must be zero or one. -#if OPENSSL_HAS_BUILTIN(__builtin_subc) +#if OPENSSL_HAS_BUILTIN(__builtin_subc) && !defined(__cplusplus) #define CRYPTO_GENERIC_SUBC(x, y, borrow, out_borrow) \ (_Generic((x), \ diff --git a/third-party/dav1d/BUILD b/third-party/dav1d/BUILD index ba022d0420..c62fdeea97 100644 --- a/third-party/dav1d/BUILD +++ b/third-party/dav1d/BUILD @@ -49,6 +49,12 @@ genrule( BUILD_ARCH="arm64" elif [ "$(TARGET_CPU)" == "ios_sim_arm64" ]; then BUILD_ARCH="sim_arm64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" + elif [ "$(TARGET_CPU)" == "k8" ] || [ "$(TARGET_CPU)" == "x86_64" ]; then + BUILD_ARCH="linux_x86_64" + elif [ "$(TARGET_CPU)" == "aarch64" ] || [ "$(TARGET_CPU)" == "arm64" ]; then + BUILD_ARCH="linux_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi @@ -57,15 +63,20 @@ genrule( rm -rf "$$BUILD_DIR" mkdir -p "$$BUILD_DIR" - MESON_DIR="$$(pwd)/$$BUILD_DIR/meson" - rm -rf "$$MESON_DIR" - mkdir -p "$$MESON_DIR" - tar -xzf "$(location @meson_tar_gz//file)" -C "$$MESON_DIR" + if [ "$${BUILD_ARCH}" = "linux_x86_64" ] || [ "$${BUILD_ARCH}" = "linux_arm64" ]; then + EXTRA_PATH="" + else + MESON_DIR="$$(pwd)/$$BUILD_DIR/meson" + rm -rf "$$MESON_DIR" + mkdir -p "$$MESON_DIR" + tar -xzf "$(location @meson_tar_gz//file)" -C "$$MESON_DIR" - NINJA_DIR="$$(pwd)/$$BUILD_DIR/ninja" - rm -rf "$$NINJA_DIR" - mkdir -p "$$NINJA_DIR" - unzip "$(location @ninja-mac_zip//file)" -d "$$NINJA_DIR" + NINJA_DIR="$$(pwd)/$$BUILD_DIR/ninja" + rm -rf "$$NINJA_DIR" + mkdir -p "$$NINJA_DIR" + unzip "$(location @ninja-mac_zip//file)" -d "$$NINJA_DIR" + EXTRA_PATH="$$MESON_DIR/meson-1.6.0:$$NINJA_DIR" + fi cp $(location :build-dav1d-bazel.sh) "$$BUILD_DIR/" cp $(location :arm64-iPhoneSimulator.meson) "$$BUILD_DIR/" @@ -78,7 +89,11 @@ genrule( mkdir -p "$$BUILD_DIR/Public/compat" mkdir -p "$$BUILD_DIR/Public/common" - PATH="$$PATH:$$MESON_DIR/meson-1.6.0:$$NINJA_DIR" sh $$BUILD_DIR/build-dav1d-bazel.sh $$BUILD_ARCH "$$BUILD_DIR" + if [ -n "$$EXTRA_PATH" ]; then + PATH="$$PATH:$$EXTRA_PATH" bash $$BUILD_DIR/build-dav1d-bazel.sh $$BUILD_ARCH "$$BUILD_DIR" + else + bash $$BUILD_DIR/build-dav1d-bazel.sh $$BUILD_ARCH "$$BUILD_DIR" + fi """ + "\n".join([ "cp -f \"$$BUILD_DIR/dav1d/build/include/{}\" \"$(location Public/{})\"".format(header, header) for header in generated_headers @@ -104,10 +119,8 @@ cc_library( srcs = [":Public/dav1d/lib/lib" + x + ".a" for x in libs] ) -objc_library( +cc_library( name = "dav1d", - module_name = "dav1d", - enable_modules = True, hdrs = [":Public/" + x for x in generated_headers], includes = [ "Public", diff --git a/third-party/dav1d/build-dav1d-bazel.sh b/third-party/dav1d/build-dav1d-bazel.sh index 6df4bd50fa..7bdb31cb5a 100644 --- a/third-party/dav1d/build-dav1d-bazel.sh +++ b/third-party/dav1d/build-dav1d-bazel.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash set -e @@ -18,6 +18,43 @@ elif [ "$ARCH" = "sim_arm64" ]; then custom_xcode_path="$(xcode-select -p)/" sed -i '' "s|/Applications/Xcode.app/Contents/Developer/|$custom_xcode_path|g" "$TARGET_CROSSFILE" CROSSFILE="../package/crossfiles/arm64-iPhoneSimulator-custom.meson" +elif [ "$ARCH" = "macos_arm64" ]; then + TARGET_CROSSFILE="$BUILD_DIR/dav1d/package/crossfiles/arm64-MacOSX-custom.meson" + custom_xcode_path="$(xcode-select -p)" + MACOS_SYSROOT="$custom_xcode_path/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" + cat > "$TARGET_CROSSFILE" << MESONEOF +[binaries] +c = ['clang', '-arch', 'arm64', '-isysroot', '$MACOS_SYSROOT'] +cpp = ['clang++', '-arch', 'arm64', '-isysroot', '$MACOS_SYSROOT'] +objc = ['clang', '-arch', 'arm64', '-isysroot', '$MACOS_SYSROOT'] +objcpp = ['clang++', '-arch', 'arm64', '-isysroot', '$MACOS_SYSROOT'] +ar = 'ar' +strip = 'strip' + +[built-in options] +c_args = ['-mmacosx-version-min=14.0'] +cpp_args = ['-mmacosx-version-min=14.0'] +c_link_args = ['-mmacosx-version-min=14.0'] +cpp_link_args = ['-mmacosx-version-min=14.0'] +objc_args = ['-mmacosx-version-min=14.0'] +objcpp_args = ['-mmacosx-version-min=14.0'] + +[properties] +root = '$custom_xcode_path/Platforms/MacOSX.platform/Developer' +needs_exe_wrapper = false + +[host_machine] +system = 'darwin' +subsystem = 'macos' +kernel = 'xnu' +cpu_family = 'aarch64' +cpu = 'arm64' +endian = 'little' +MESONEOF + CROSSFILE="../package/crossfiles/arm64-MacOSX-custom.meson" +elif [ "$ARCH" = "linux_arm64" ] || [ "$ARCH" = "linux_x86_64" ]; then + # Native Linux build - no cross file needed + CROSSFILE="" else echo "Unsupported architecture $ARCH" exit 1 @@ -28,7 +65,13 @@ rm -rf build mkdir build pushd build -meson.py setup .. --cross-file="$CROSSFILE" $MESON_OPTIONS +MESON_CMD=$(command -v meson.py 2>/dev/null || command -v meson 2>/dev/null || echo meson.py) + +if [ -n "$CROSSFILE" ]; then + $MESON_CMD setup .. --cross-file="$CROSSFILE" $MESON_OPTIONS +else + $MESON_CMD setup .. $MESON_OPTIONS +fi ninja popd diff --git a/third-party/libjxl/BUILD b/third-party/libjxl/BUILD index 58ab6bebee..d1c7975ef7 100644 --- a/third-party/libjxl/BUILD +++ b/third-party/libjxl/BUILD @@ -57,6 +57,8 @@ genrule( BUILD_ARCH="sim_arm64" elif [ "$(TARGET_CPU)" == "ios_x86_64" ]; then BUILD_ARCH="x86_64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi diff --git a/third-party/libjxl/build-libjxl-bazel.sh b/third-party/libjxl/build-libjxl-bazel.sh index d63765f2af..5e35ba1aca 100755 --- a/third-party/libjxl/build-libjxl-bazel.sh +++ b/third-party/libjxl/build-libjxl-bazel.sh @@ -33,7 +33,24 @@ elif [ "$ARCH" = "sim_arm64" ]; then IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/iPhoneSimulator*.sdk) export CFLAGS="-Wall -arch arm64 --target=arm64-apple-ios13.0-simulator -miphonesimulator-version-min=13.0 -funwind-tables" export CXXFLAGS="$CFLAGS" - + + cd "$BUILD_DIR" + mkdir build + cd build + + touch toolchain.cmake + echo "set(CMAKE_SYSTEM_NAME Darwin)" >> toolchain.cmake + echo "set(CMAKE_SYSTEM_PROCESSOR aarch64)" >> toolchain.cmake + echo "set(CMAKE_C_COMPILER $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang)" >> toolchain.cmake + + cmake -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -DCMAKE_OSX_SYSROOT=${IOS_SYSROOT[0]} $CMAKE_OPTIONS ../libjxl + make +elif [ "$ARCH" = "macos_arm64" ]; then + IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/MacOSX.platform" + IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/MacOSX*.sdk) + export CFLAGS="-Wall -arch arm64 --target=arm64-apple-macosx14.0 -mmacosx-version-min=14.0 -funwind-tables" + export CXXFLAGS="$CFLAGS" + cd "$BUILD_DIR" mkdir build cd build diff --git a/third-party/libvpx/BUILD b/third-party/libvpx/BUILD index 9040681907..5c34bd2ad9 100644 --- a/third-party/libvpx/BUILD +++ b/third-party/libvpx/BUILD @@ -49,6 +49,15 @@ genrule( elif [ "$(TARGET_CPU)" == "ios_x86_64" ]; then BUILD_ARCH="x86_64" PLATFORM_HEADER_DIR="x86_64-iphonesimulator-gcc" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" + PLATFORM_HEADER_DIR="arm64-darwin22-gcc" + elif [ "$(TARGET_CPU)" == "k8" ] || [ "$(TARGET_CPU)" == "x86_64" ]; then + BUILD_ARCH="linux_x86_64" + PLATFORM_HEADER_DIR="x86_64-linux-gcc" + elif [ "$(TARGET_CPU)" == "aarch64" ] || [ "$(TARGET_CPU)" == "arm64" ]; then + BUILD_ARCH="linux_arm64" + PLATFORM_HEADER_DIR="arm64-linux-gcc" else echo "Unsupported architecture $(TARGET_CPU)" fi @@ -76,7 +85,7 @@ genrule( mkdir -p "$$BUILD_DIR/Public/libvpx" - PATH="$$PATH:$$ABS_YASM_DIR" sh $$BUILD_DIR/build-libvpx-bazel.sh $$BUILD_ARCH "$$BUILD_DIR/libvpx" "$$BUILD_DIR" + PATH="$$PATH:$$ABS_YASM_DIR" bash $$BUILD_DIR/build-libvpx-bazel.sh $$BUILD_ARCH "$$BUILD_DIR/libvpx" "$$BUILD_DIR" """ + "\n".join([ "cp -f \"$$BUILD_DIR/VPX.framework/Headers/vpx/{}\" \"$(location Public/vpx/{})\"".format(header, header) for header in headers @@ -102,10 +111,8 @@ cc_library( srcs = [":Public/vpx/lib" + x + ".a" for x in libs], ) -objc_library( +cc_library( name = "vpx", - module_name = "vpx", - enable_modules = True, hdrs = [":Public/vpx/" + x for x in headers], includes = [ "Public", diff --git a/third-party/libvpx/build-libvpx-bazel.sh b/third-party/libvpx/build-libvpx-bazel.sh old mode 100755 new mode 100644 index 991aab1293..775886ba71 --- a/third-party/libvpx/build-libvpx-bazel.sh +++ b/third-party/libvpx/build-libvpx-bazel.sh @@ -1,4 +1,4 @@ -#! /bin/sh +#!/bin/bash set -e set -x @@ -33,18 +33,34 @@ SCRIPT_DIR="$SOURCE_DIR" LIBVPX_SOURCE_DIR="$SOURCE_DIR" -LIPO=$(xcrun -sdk iphoneos${SDK} -find lipo) - ORIG_PWD="$(pwd)" +EXTRA_CONFIGURE_ARGS="" + if [ "$ARCH" = "armv7" ]; then TARGETS="armv7-darwin-gcc" + LIPO=$(xcrun -sdk iphoneos${SDK} -find lipo) elif [ "$ARCH" = "arm64" ]; then TARGETS="arm64-darwin-gcc" + LIPO=$(xcrun -sdk iphoneos${SDK} -find lipo) elif [ "$ARCH" = "sim_arm64" ]; then TARGETS="arm64-iphonesimulator-gcc" + LIPO=$(xcrun -sdk iphoneos${SDK} -find lipo) +elif [ "$ARCH" = "macos_arm64" ]; then + TARGETS="arm64-darwin22-gcc" + LIPO=$(xcrun -sdk macosx -find lipo) + export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) +elif [ "$ARCH" = "linux_arm64" ]; then + TARGETS="arm64-linux-gcc" + LIPO="cp" + EXTRA_CONFIGURE_ARGS="--enable-pic" +elif [ "$ARCH" = "linux_x86_64" ]; then + TARGETS="x86_64-linux-gcc" + LIPO="cp" + EXTRA_CONFIGURE_ARGS="--enable-pic" elif [ "$ARCH" = "x86_64" ]; then TARGETS="x86_64-iphonesimulator-gcc" + LIPO=$(xcrun -sdk iphoneos${SDK} -find lipo) else echo "Unsupported architecture $ARCH" exit 1 @@ -167,7 +183,11 @@ build_framework() { cp -p "${target_dist_dir}"/include/vpx/* "${HEADER_DIR}" # Build the fat library. - ${LIPO} -create ${lib_list} -output ${FRAMEWORK_DIR}/VPX + if [ "$LIPO" = "cp" ]; then + cp ${lib_list} ${FRAMEWORK_DIR}/VPX + else + ${LIPO} -create ${lib_list} -output ${FRAMEWORK_DIR}/VPX + fi # Create the vpx_config.h shim that allows usage of vpx_config.h from # within VPX.framework. diff --git a/third-party/libyuv/BUILD b/third-party/libyuv/BUILD index 14e2255c5e..dbabb4e81f 100644 --- a/third-party/libyuv/BUILD +++ b/third-party/libyuv/BUILD @@ -25,7 +25,9 @@ arch_specific_cflags = select({ "@build_bazel_rules_apple//apple:ios_arm64": common_flags + arm64_specific_flags, "//build-system:ios_sim_arm64": common_flags + arm64_specific_flags, "@build_bazel_rules_apple//apple:ios_x86_64": common_flags + x86_64_specific_flags, - "//conditions:default": common_flags, + "//build-system:linux_arm64": common_flags + arm64_specific_flags, + "//build-system:linux_x86_64": common_flags + x86_64_specific_flags, + "//conditions:default": common_flags + arm64_specific_flags, }) cc_library( diff --git a/third-party/mozjpeg/BUILD b/third-party/mozjpeg/BUILD index b74836738d..2c31552b90 100644 --- a/third-party/mozjpeg/BUILD +++ b/third-party/mozjpeg/BUILD @@ -36,6 +36,8 @@ genrule( BUILD_ARCH="sim_arm64" elif [ "$(TARGET_CPU)" == "ios_x86_64" ]; then BUILD_ARCH="x86_64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi diff --git a/third-party/mozjpeg/build-mozjpeg-bazel.sh b/third-party/mozjpeg/build-mozjpeg-bazel.sh index c115e26c97..1ebdf75e3f 100755 --- a/third-party/mozjpeg/build-mozjpeg-bazel.sh +++ b/third-party/mozjpeg/build-mozjpeg-bazel.sh @@ -37,6 +37,22 @@ elif [ "$ARCH" = "sim_arm64" ]; then echo "set(CMAKE_SYSTEM_PROCESSOR aarch64)" >> toolchain.cmake echo "set(CMAKE_C_COMPILER $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang)" >> toolchain.cmake + cmake -G"Unix Makefiles" -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -DCMAKE_OSX_SYSROOT=${IOS_SYSROOT[0]} -DPNG_SUPPORTED=FALSE -DENABLE_SHARED=FALSE -DWITH_JPEG8=1 -DBUILD=10000 -DCMAKE_POLICY_VERSION_MINIMUM=3.5 ../mozjpeg + make +elif [ "$ARCH" = "macos_arm64" ]; then + IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/MacOSX.platform" + IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/MacOSX*.sdk) + export CFLAGS="-Wall -arch arm64 --target=arm64-apple-macosx14.0 -mmacosx-version-min=14.0 -funwind-tables" + + cd "$BUILD_DIR" + mkdir build + cd build + + touch toolchain.cmake + echo "set(CMAKE_SYSTEM_NAME Darwin)" >> toolchain.cmake + echo "set(CMAKE_SYSTEM_PROCESSOR aarch64)" >> toolchain.cmake + echo "set(CMAKE_C_COMPILER $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang)" >> toolchain.cmake + cmake -G"Unix Makefiles" -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -DCMAKE_OSX_SYSROOT=${IOS_SYSROOT[0]} -DPNG_SUPPORTED=FALSE -DENABLE_SHARED=FALSE -DWITH_JPEG8=1 -DBUILD=10000 -DCMAKE_POLICY_VERSION_MINIMUM=3.5 ../mozjpeg make else diff --git a/third-party/ogg/BUILD b/third-party/ogg/BUILD index 1da3eaa02d..5c11a1409b 100644 --- a/third-party/ogg/BUILD +++ b/third-party/ogg/BUILD @@ -1,8 +1,6 @@ - -objc_library( + +cc_library( name = "ogg", - enable_modules = True, - module_name = "ogg", srcs = glob([ "Sources/*.c", "Sources/*.h", diff --git a/third-party/ogg/include/ogg/config_types.h b/third-party/ogg/include/ogg/config_types.h new file mode 100644 index 0000000000..de228d380f --- /dev/null +++ b/third-party/ogg/include/ogg/config_types.h @@ -0,0 +1,15 @@ +#ifndef __CONFIG_TYPES_H__ +#define __CONFIG_TYPES_H__ + +/* Generated for Linux builds */ + +#include + +typedef int16_t ogg_int16_t; +typedef uint16_t ogg_uint16_t; +typedef int32_t ogg_int32_t; +typedef uint32_t ogg_uint32_t; +typedef int64_t ogg_int64_t; +typedef uint64_t ogg_uint64_t; + +#endif diff --git a/third-party/openh264/BUILD b/third-party/openh264/BUILD index 845e670c25..46940c42c9 100644 --- a/third-party/openh264/BUILD +++ b/third-party/openh264/BUILD @@ -57,18 +57,21 @@ arch_specific_sources = select({ "@build_bazel_rules_apple//apple:ios_arm64": arm64_specific_sources, "//build-system:ios_sim_arm64": arm64_specific_sources, "@build_bazel_rules_apple//apple:ios_x86_64": [], + "//conditions:default": arm64_specific_sources, }) arch_specific_copts = select({ "@build_bazel_rules_apple//apple:ios_arm64": arm64_specific_copts, "//build-system:ios_sim_arm64": arm64_specific_copts, "@build_bazel_rules_apple//apple:ios_x86_64": [], + "//conditions:default": arm64_specific_copts, }) arch_specific_textual_hdrs = select({ "@build_bazel_rules_apple//apple:ios_arm64": arm64_specific_textual_hdrs, "//build-system:ios_sim_arm64": arm64_specific_textual_hdrs, "@build_bazel_rules_apple//apple:ios_x86_64": [], + "//conditions:default": arm64_specific_textual_hdrs, }) all_sources = arch_specific_sources + [ diff --git a/third-party/opus/BUILD b/third-party/opus/BUILD index 873cdda53d..d80e343284 100644 --- a/third-party/opus/BUILD +++ b/third-party/opus/BUILD @@ -33,6 +33,8 @@ genrule( BUILD_ARCH="linux_x86_64" elif [ "$(TARGET_CPU)" == "aarch64" ] || [ "$(TARGET_CPU)" == "arm64" ]; then BUILD_ARCH="linux_arm64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi @@ -81,10 +83,8 @@ cc_library( ], ) -objc_library( +cc_library( name = "opus", - module_name = "opus", - enable_modules = True, hdrs = [":Public/opus/" + x for x in headers], includes = [ "Public", diff --git a/third-party/opus/build-opus-bazel.sh b/third-party/opus/build-opus-bazel.sh index bb79c9efc6..74e6692762 100755 --- a/third-party/opus/build-opus-bazel.sh +++ b/third-party/opus/build-opus-bazel.sh @@ -54,6 +54,10 @@ elif [ "${ARCH}" == "sim_arm64" ]; then PLATFORM="iphonesimulator" EXTRA_CFLAGS="-arch arm64 --target=arm64-apple-ios$MINIOSVERSION-simulator" EXTRA_CONFIG="--host=arm-apple-darwin20" +elif [ "${ARCH}" == "macos_arm64" ]; then + PLATFORM="macosx" + EXTRA_CFLAGS="-arch arm64 --target=arm64-apple-macosx14.0" + EXTRA_CONFIG="--host=arm-apple-darwin20" else PLATFORM="iphoneos" EXTRA_CFLAGS="-arch ${ARCH}" diff --git a/third-party/opusfile/BUILD b/third-party/opusfile/BUILD index 0e62d353df..f28b1d6e46 100644 --- a/third-party/opusfile/BUILD +++ b/third-party/opusfile/BUILD @@ -1,8 +1,6 @@ - -objc_library( + +cc_library( name = "opusfile", - enable_modules = True, - module_name = "opusfile", srcs = glob([ "Sources/*.c", "Sources/*.h", diff --git a/third-party/rnnoise/BUILD b/third-party/rnnoise/BUILD index e1066c862b..d260114645 100644 --- a/third-party/rnnoise/BUILD +++ b/third-party/rnnoise/BUILD @@ -24,11 +24,9 @@ replace_symbol_list = [ "pitch_search", "remove_doubling", ] - -objc_library( + +cc_library( name = "rnnoise", - enable_modules = True, - module_name = "rnnoise", srcs = glob([ "Sources/*.c", "Sources/*.h", diff --git a/third-party/td/BUILD b/third-party/td/BUILD index 365f56ee82..f84f29c97b 100644 --- a/third-party/td/BUILD +++ b/third-party/td/BUILD @@ -38,6 +38,8 @@ genrule( BUILD_ARCH="arm64" elif [ "$(TARGET_CPU)" == "ios_sim_arm64" ]; then BUILD_ARCH="sim_arm64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi diff --git a/third-party/td/build-td-bazel.sh b/third-party/td/build-td-bazel.sh index a977bbb10d..d96b410523 100755 --- a/third-party/td/build-td-bazel.sh +++ b/third-party/td/build-td-bazel.sh @@ -34,6 +34,10 @@ elif [ "$ARCH" = "sim_arm64" ]; then IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/iPhoneSimulator.platform" IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/iPhoneSimulator*.sdk) export CFLAGS="-arch arm64 --target=arm64-apple-ios13.0-simulator -miphonesimulator-version-min=13.0" +elif [ "$ARCH" = "macos_arm64" ]; then + IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/MacOSX.platform" + IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/MacOSX*.sdk) + export CFLAGS="-arch arm64 --target=arm64-apple-macosx14.0 -mmacosx-version-min=14.0" else echo "Unsupported architecture $ARCH" exit 1 diff --git a/third-party/webp/BUILD b/third-party/webp/BUILD index 82b7278c8e..2541873587 100644 --- a/third-party/webp/BUILD +++ b/third-party/webp/BUILD @@ -35,6 +35,8 @@ genrule( BUILD_ARCH="sim_arm64" elif [ "$(TARGET_CPU)" == "ios_x86_64" ]; then BUILD_ARCH="x86_64" + elif [ "$(TARGET_CPU)" == "darwin_arm64" ]; then + BUILD_ARCH="macos_arm64" else echo "Unsupported architecture $(TARGET_CPU)" fi diff --git a/third-party/webp/build-webp-bazel.sh b/third-party/webp/build-webp-bazel.sh index 7a3d123187..dcbb665938 100755 --- a/third-party/webp/build-webp-bazel.sh +++ b/third-party/webp/build-webp-bazel.sh @@ -41,6 +41,22 @@ elif [ "$ARCH" = "sim_arm64" ]; then echo "set(CMAKE_SYSTEM_PROCESSOR aarch64)" >> toolchain.cmake echo "set(CMAKE_C_COMPILER $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang)" >> toolchain.cmake + cmake -G"Unix Makefiles" -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -DCMAKE_OSX_SYSROOT=${IOS_SYSROOT[0]} $COMMON_ARGS ../libwebp + make +elif [ "$ARCH" = "macos_arm64" ]; then + IOS_PLATFORMDIR="$(xcode-select -p)/Platforms/MacOSX.platform" + IOS_SYSROOT=($IOS_PLATFORMDIR/Developer/SDKs/MacOSX*.sdk) + export CFLAGS="-Wall -arch arm64 --target=arm64-apple-macosx14.0 -mmacosx-version-min=14.0 -funwind-tables" + + cd "$BUILD_DIR" + mkdir build + cd build + + touch toolchain.cmake + echo "set(CMAKE_SYSTEM_NAME Darwin)" >> toolchain.cmake + echo "set(CMAKE_SYSTEM_PROCESSOR aarch64)" >> toolchain.cmake + echo "set(CMAKE_C_COMPILER $(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang)" >> toolchain.cmake + cmake -G"Unix Makefiles" -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake -DCMAKE_OSX_SYSROOT=${IOS_SYSROOT[0]} $COMMON_ARGS ../libwebp make elif [ "$ARCH" = "x86_64" ]; then diff --git a/third-party/webrtc/BUILD b/third-party/webrtc/BUILD index 37837b1606..954e853949 100644 --- a/third-party/webrtc/BUILD +++ b/third-party/webrtc/BUILD @@ -15,7 +15,6 @@ optimization_flags = select({ }) webrtc_objcpp_sources = [ - "rtc_base/system/cocoa_threading.mm", ] webrtc_headers = [ @@ -2767,46 +2766,58 @@ arm64_specific_sources = ["webrtc/" + path for path in [ arch_specific_sources = select({ "@build_bazel_rules_apple//apple:ios_arm64": common_arm_specific_sources + arm64_specific_sources, "//build-system:ios_sim_arm64": common_arm_specific_sources + arm64_specific_sources, + "//conditions:default": common_arm_specific_sources + arm64_specific_sources, }) -common_flags = [ - "-DWEBRTC_IOS", - "-DWEBRTC_MAC", +platform_shared_flags = [ "-DWEBRTC_POSIX", "-DHAVE_WEBRTC_VIDEO", "-DRTC_ENABLE_VP9", "-DRTC_ENABLE_H265", "-DWEBRTC_USE_H264", + "-DWEBRTC_USE_H264_DECODER", "-DHAVE_SCTP", "-DWEBRTC_HAVE_DCSCTP", "-DWEBRTC_HAVE_SCTP", "-DWEBRTC_NS_FLOAT", "-DRTC_DISABLE_TRACE_EVENTS", - #"-DWEBRTC_OPUS_SUPPORT_120MS_PTIME=1", "-DWEBRTC_APM_DEBUG_DUMP=0", "-DBWE_TEST_LOGGING_COMPILE_TIME_ENABLE=0", "-DABSL_ALLOCATOR_NOTHROW=1", "-DDYNAMIC_ANNOTATIONS_ENABLED=0", - #"-DNS_BLOCK_ASSERTIONS=1", "-DWEBRTC_ENABLE_PROTOBUF=0", - "-DWEBRTC_ENABLE_AVX2", "-DWEBRTC_NON_STATIC_TRACE_EVENT_HANDLERS=0", - "-Wno-shorten-64-to-32", - "-Wno-macro-redefined", - "-D__APPLE__", "-DWEBRTC_OPUS_USE_CODEC_PLC", "-DWEBRTC_OPUS_SUPPORT_DRED", ] +apple_specific_flags = [ + "-DWEBRTC_IOS", + "-DWEBRTC_MAC", + "-D__APPLE__", + "-D__Userspace_os_Darwin", + "-DWEBRTC_ENABLE_AVX2", + "-Wno-shorten-64-to-32", + "-Wno-macro-redefined", +] + +linux_specific_flags = [ + "-DWEBRTC_LINUX", + "-D__Userspace_os_Linux", +] + arm64_specific_flags = [ "-DWEBRTC_ARCH_ARM64", "-DWEBRTC_HAS_NEON", "-DLIBYUV_NEON", ] +# Flatten platform + arch flags into a single select to avoid nested selects arch_specific_cflags = select({ - "@build_bazel_rules_apple//apple:ios_arm64": common_flags + arm64_specific_flags, - "//build-system:ios_sim_arm64": common_flags + arm64_specific_flags, + "@build_bazel_rules_apple//apple:ios_arm64": platform_shared_flags + apple_specific_flags + arm64_specific_flags, + "//build-system:ios_sim_arm64": platform_shared_flags + apple_specific_flags + arm64_specific_flags, + "@platforms//os:linux": platform_shared_flags + linux_specific_flags + arm64_specific_flags, + "//conditions:default": platform_shared_flags + apple_specific_flags + arm64_specific_flags, }) dcsctp_sources = [ "webrtc/net/dcsctp/" + path for path in [ @@ -2981,11 +2992,44 @@ fft4g_sources = [ "fft4g/fft4g.cc", ] -raw_combined_sources = webrtc_headers + webrtc_sources + webrtc_objcpp_sources -combined_sources = [ - "webrtc/" + path for path in raw_combined_sources +raw_combined_cpp_sources = webrtc_headers + webrtc_sources +raw_combined_objcpp_sources = webrtc_objcpp_sources + +# Platform-specific source files: GCD task queue on Apple, stdlib on Linux +apple_only_cpp_sources = [ + "webrtc/rtc_base/task_queue_gcd.cc", + "webrtc/rtc_base/task_queue_gcd.h", + "webrtc/api/task_queue/default_task_queue_factory_gcd.cc", + "webrtc/rtc_base/mac_ifaddrs_converter.cc", +] + +linux_only_cpp_sources = [ + "webrtc/rtc_base/task_queue_stdlib.cc", + "webrtc/rtc_base/task_queue_stdlib.h", + "webrtc/api/task_queue/default_task_queue_factory_stdlib.cc", +] + +# Files excluded from common sources because they are platform-specific +platform_specific_excludes = [ + "rtc_base/task_queue_gcd.cc", "rtc_base/task_queue_gcd.h", + "rtc_base/task_queue_stdlib.cc", "rtc_base/task_queue_stdlib.h", + "rtc_base/mac_ifaddrs_converter.cc", + "api/task_queue/default_task_queue_factory_gcd.cc", + "api/task_queue/default_task_queue_factory_stdlib.cc", + "api/task_queue/default_task_queue_factory_libevent.cc", + "api/task_queue/default_task_queue_factory_win.cc", + "api/task_queue/default_task_queue_factory_stdlib_or_libevent_experiment.cc", +] + +combined_cpp_sources = [ + "webrtc/" + path for path in raw_combined_cpp_sources + if path not in platform_specific_excludes ] + arch_specific_sources + fft4g_sources + dcsctp_sources +combined_objcpp_sources = [ + "webrtc/" + path for path in raw_combined_objcpp_sources +] + genrule( name = "generate_field_trials_header", srcs = [ @@ -3118,13 +3162,15 @@ objc_library( visibility = ["//visibility:public"], ) -objc_library( +cc_library( name = "webrtc", - enable_modules = True, - module_name = "webrtc", - srcs = combined_sources, + srcs = combined_cpp_sources + select({ + "@platforms//os:linux": linux_only_cpp_sources, + "//conditions:default": combined_objcpp_sources + apple_only_cpp_sources, + }), copts = [ "-w", + "-include", "stdint.h", "-Ithird-party/webrtc/libsrtp/third_party/libsrtp/include", "-Ithird-party/webrtc/libsrtp/third_party/libsrtp/crypto/include", "-Ithird-party/webrtc/libsrtp", @@ -3140,7 +3186,6 @@ objc_library( "-DSCTP_SIMPLE_ALLOCATOR", "-DSCTP_PROCESS_LEVEL_LOCKS", "-D__Userspace__", - "-D__Userspace_os_Darwin", "-DPACKAGE_VERSION=\\\"\\\"", "-DHAVE_SCTP", "-DWEBRTC_HAVE_DCSCTP", @@ -3168,20 +3213,49 @@ objc_library( "//third-party/webrtc/pffft", "//third-party/webrtc/libsrtp", "//third-party/webrtc/absl", + ] + select({ + "@platforms//os:linux": [], + "//conditions:default": [":webrtc_platform_helpers"], + }), + linkopts = select({ + "@platforms//os:linux": ["-lpthread", "-lm"], + "//conditions:default": [ + "-framework AVFoundation", + "-framework AudioToolbox", + "-framework VideoToolbox", + "-framework CoreMedia", + "-framework CoreVideo", + "-framework CoreGraphics", + "-framework QuartzCore", + "-framework AppKit", + "-weak_framework Network", + "-weak_framework Metal", + ], + }), + visibility = ["//visibility:public"], +) + +# Minimal helpers needed by tgcalls C++ code on non-iOS platforms. +# Separate from webrtc_objc to avoid pulling in iOS SDK dependencies. +objc_library( + name = "webrtc_platform_helpers", + srcs = [ + "webrtc/rtc_base/system/gcd_helpers.m", + "webrtc/rtc_base/system/cocoa_threading.mm", ], - sdk_frameworks = [ - "AVFoundation", - "AudioToolbox", - "VideoToolbox", - "UIKit", - "CoreMedia", - "CoreVideo", - "CoreGraphics", - "QuartzCore", + hdrs = [ + "webrtc/rtc_base/system/gcd_helpers.h", + "webrtc/rtc_base/system/cocoa_threading.h", ], - weak_sdk_frameworks = [ - "Network", - "Metal", + copts = [ + "-w", + "-Ithird-party/webrtc/webrtc/", + "-Ithird-party/webrtc/absl", + "-DWEBRTC_MAC", + "-DWEBRTC_IOS", + ], + deps = [ + "//third-party/webrtc/absl", ], visibility = ["//visibility:public"], ) diff --git a/third-party/webrtc/absl/absl/base/attributes.h b/third-party/webrtc/absl/absl/base/attributes.h index 38086a82e4..6f61871041 100644 --- a/third-party/webrtc/absl/absl/base/attributes.h +++ b/third-party/webrtc/absl/absl/base/attributes.h @@ -808,13 +808,8 @@ // // See also the upstream documentation: // https://clang.llvm.org/docs/AttributeReference.html#lifetimebound -#if ABSL_HAVE_CPP_ATTRIBUTE(clang::lifetimebound) -#define ABSL_ATTRIBUTE_LIFETIME_BOUND [[clang::lifetimebound]] -#elif ABSL_HAVE_ATTRIBUTE(lifetimebound) -#define ABSL_ATTRIBUTE_LIFETIME_BOUND __attribute__((lifetimebound)) -#else +// Disabled: newer clang rejects lifetimebound on void-returning functions #define ABSL_ATTRIBUTE_LIFETIME_BOUND -#endif // ABSL_ATTRIBUTE_TRIVIAL_ABI // Indicates that a type is "trivially relocatable" -- meaning it can be diff --git a/third-party/webrtc/webrtc b/third-party/webrtc/webrtc index dfd6b604d7..adb369cb79 160000 --- a/third-party/webrtc/webrtc +++ b/third-party/webrtc/webrtc @@ -1 +1 @@ -Subproject commit dfd6b604d7194a3d41614afa2c8abd8825a657aa +Subproject commit adb369cb796e6cc886e92d1faccc4a97c16a4685 diff --git a/third-party/yasm/BUILD b/third-party/yasm/BUILD index ecfef261b0..1d0e523fba 100644 --- a/third-party/yasm/BUILD +++ b/third-party/yasm/BUILD @@ -16,7 +16,7 @@ genrule( cmd_bash = """ set -x - core_count=`PATH="$$PATH:/usr/sbin" sysctl -n hw.logicalcpu` + core_count=$$(nproc 2>/dev/null || PATH="$$PATH:/usr/sbin" sysctl -n hw.logicalcpu 2>/dev/null || echo 4) BUILD_DIR="$(RULEDIR)/build" rm -rf "$$BUILD_DIR" mkdir -p "$$BUILD_DIR" @@ -25,14 +25,24 @@ set -x rm -rf "$$CMAKE_DIR" mkdir -p "$$CMAKE_DIR" tar -xf "$(location @cmake_tar_gz//file)" -C "$$CMAKE_DIR" - + + # Find cmake: try system cmake first, then downloaded macOS cmake + if command -v cmake >/dev/null 2>&1; then + CMAKE_BIN="cmake" + elif [ -d "$$CMAKE_DIR/cmake-4.1.2-macos-universal/CMake.app/Contents/bin" ]; then + CMAKE_BIN="$$CMAKE_DIR/cmake-4.1.2-macos-universal/CMake.app/Contents/bin/cmake" + else + echo "cmake not found" + exit 1 + fi + SOURCE_PATH="third-party/yasm/yasm-1.3.0" cp -R "$$SOURCE_PATH" "$$BUILD_DIR/" pushd "$$BUILD_DIR/yasm-1.3.0" mkdir build cd build - PATH="$$CMAKE_DIR/cmake-4.1.2-macos-universal/CMake.app/Contents/bin:$$PATH" cmake .. -DYASM_BUILD_TESTS=OFF -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DPYTHON_EXECUTABLE="$$(which python3)" + "$$CMAKE_BIN" .. -DYASM_BUILD_TESTS=OFF -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DPYTHON_EXECUTABLE="$$(which python3)" make -j $$core_count popd diff --git a/tools/go_sfu/BUILD b/tools/go_sfu/BUILD new file mode 100644 index 0000000000..4abfe207a0 --- /dev/null +++ b/tools/go_sfu/BUILD @@ -0,0 +1,18 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary") + +go_binary( + name = "go_sfu", + srcs = glob(["*.go"]), + cgo = True, + linkmode = "c-archive", + visibility = ["//visibility:public"], + deps = [ + "@com_github_pion_datachannel//:datachannel", + "@com_github_pion_dtls_v3//:dtls", + "@com_github_pion_ice_v4//:ice", + "@com_github_pion_logging//:logging", + "@com_github_pion_rtcp//:rtcp", + "@com_github_pion_sctp//:sctp", + "@com_github_pion_srtp_v3//:srtp", + ], +) diff --git a/tools/go_sfu/CLAUDE.md b/tools/go_sfu/CLAUDE.md new file mode 100644 index 0000000000..ee33a94673 --- /dev/null +++ b/tools/go_sfu/CLAUDE.md @@ -0,0 +1,107 @@ +# Go/Pion SFU + +The group call test mode uses a Go-based SFU (Selective Forwarding Unit) built with [Pion WebRTC](https://github.com/pion/webrtc), linked into the C++ `tgcalls_cli` binary via CGo. + +## Build Integration +- `MODULE.bazel` — `rules_go` 0.60.0 + Go SDK 1.24.2 + `gazelle` 0.43.0; Pion dependencies managed via `go_deps` Gazelle extension +- `tools/go_sfu/BUILD` — `go_binary` with `linkmode = "c-archive"` produces a static archive + CGo header, exposes `CcInfo` to C++ targets +- `tools/go_sfu/go.mod` / `go.sum` — Pion dependency declarations (pion/ice, pion/dtls, pion/srtp, pion/sctp) +- `tools/tgcalls_cli/BUILD` — depends on `//tools/go_sfu` to link the Go archive +- The CGo-generated header is included as `#include "tools/go_sfu/go_sfu.h"` in C++ code + +## How It Works +The `go_binary` with `linkmode = "c-archive"` compiles Go code (including the Go runtime) into a `.a` static archive. Functions annotated with `//export` in Go become C-callable symbols. Bazel's `rules_go` automatically provides `CcInfo`, so `cc_binary` targets can depend on the Go archive via `deps` — no manual linkopts needed. + +The Go runtime (GC, goroutine scheduler) runs inside the C++ process. This adds ~10MB memory overhead. `GoSfu_Init()` must be called before any other Go functions. + +## Key Files +- `tools/go_sfu/sfu.go` — SFU core: participant registry, join/leave/response flow, audio+video RTP forwarding, SSRC registry (audio/video/video-rtx with layer index), Colibri `ReceiverVideoConstraints`/`SenderVideoConstraints` handling, PLI/FIR forwarding, `ActiveAudioSsrcs`/`ActiveVideoSsrcs` broadcasting, `//export` C bindings (`GoSfu_Init`, `GoSfu_Create`, `GoSfu_Destroy`, `GoSfu_Join`, `GoSfu_Leave`, `GoSfu_QuerySsrc`, `GoSfu_QueryVideoSsrcs`, `GoSfu_Free`, `GoSfu_Shutdown`) +- `tools/go_sfu/participant.go` — per-participant transport stack (ICE agent, DTLS conn, SRTP session, SRTCP contexts for manual RTCP decrypt/encrypt, SCTP association, data channel send/receive, per-receiver video layer selection) +- `tools/go_sfu/mux.go` — packet demuxer: three-way split of ICE traffic into DTLS handshake, SRTP (RTP), and SRTCP (RTCP) channels per RFC 7983 + RFC 5761 +- `tools/go_sfu/go.mod` / `go.sum` — Go module with Pion dependencies +- `tools/tgcalls_cli/group_mode.cpp` — C++ side that drives the group join flow and calls into Go SFU +- `tools/tgcalls_cli/group_mode.h` — header for group mode entry point +- `tools/tgcalls_cli/group_participant.h/.cpp` — shared participant lifecycle helpers (`createParticipant`, `stopParticipant`, `validateGroupState`, `printGroupSummary`), `ParticipantState` struct, audio helpers +- `tools/tgcalls_cli/group_churn_mode.h/.cpp` — group-churn stress test: base group + rapid join/leave cycling + +## SFU Bandwidth Adaptation + +The SFU implements REMB-based bandwidth-adaptive simulcast layer selection for video. Per receiver, it maintains an EWMA-smoothed bandwidth estimate from REMB RTCP feedback and uses a `LayerSelector` state machine per (receiver, sender) pair to decide which simulcast layer to forward. + +### State Machine +- **STABLE**: forwarding current layer. Checks for upswitch opportunity (REMB > threshold × 1.2) or downswitch need (REMB < threshold × 0.7). +- **PROBING_UP**: ramping RTX padding from 0 to the gap between current and target layer bitrate over 2 seconds. Aborts if REMB drops; succeeds if REMB sustains. +- **GRACE_DOWN**: REMB below downswitch threshold. Waits 500ms, then downswitches if not recovered. 5-second cooldown after any switch. + +### Layer Thresholds +| Layer | Nominal | Upswitch When | Downswitch When | +|-------|---------|--------------|-----------------| +| 0 | 60 kbps | (start) | (never) | +| 1 | 110 kbps | BW > 132 kbps | BW < 77 kbps | +| 2 | 900 kbps | BW > 1,080 kbps | BW < 630 kbps | + +### Layer Selection and SSRC Rewriting +The SFU forwards exactly one simulcast layer per (receiver, sender) pair. Before `ReceiverVideoConstraints` arrives, the SFU uses `requestedLayer` as the cap and forwards at `maxActiveLayer` (the highest layer the encoder actually produces). After constraints arrive, `ensureLayerSelector` sets `selectedLayer` clamped to `maxActiveLayer`. + +When forwarding a non-base layer, the SFU rewrites the RTP SSRC to the primary (layer 0) SSRC. This is necessary because `IncomingVideoChannel` in CustomImpl attaches its `VideoSinkImpl` to `_mainVideoSsrc` (the first SSRC in the SIM group, i.e., layer 0). Without SSRC rewriting, packets from higher layers are delivered to the wrong receive stream and never decoded. RTX SSRCs are similarly rewritten to the layer 0 FID SSRC. + +### Testing on Localhost +Use `--network-scenario step-down-up` to exercise the full adaptation path via per-client network simulation (replaces the old REMB-override `--bw-scenario`). + +```bash +# Network scenario test (30s, 4 phases: uncapped → 80k egress → 200k → uncapped) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 30 --network-scenario step-down-up +``` + +Unit tests drive the `LayerSelector` state machine directly via mocked callbacks. Run from `tools/go_sfu/`: + +```bash +go test -run TestLayerSelector -v -timeout 60s +``` + +Covers upswitch L0→L1→L2, downswitch L2→L1→L0, grace-down recovery on transient dips, stale-BW idle behavior, the `OnMaxActiveLayerIncreased` fallback used when clients don't send REMB, and `maxLayer` enforcement. + +### REMB-free Fallback + +Real tgcalls clients negotiate `goog-remb` but use transport-cc as the primary BWE signal, so no REMB actually arrives at the SFU. This means the REMB-driven state machine never enters `PROBING_UP` in live runs. `LayerSelector.OnMaxActiveLayerIncreased(maxActive)` is the fallback: when the sender starts producing a higher simulcast layer than previously seen AND the BW estimate is stale, the SFU immediately upshifts to the highest available layer (clamped by `maxLayer`). Called from `sfu.go`'s packet-forwarding path whenever `maxActiveLayer[senderID]` is bumped. + +### Key Files +- `tools/go_sfu/bandwidth.go` — `BandwidthEstimator`, `LayerSelector`, `RtxRingBuffer`, `OnMaxActiveLayerIncreased` +- `tools/go_sfu/bandwidth_test.go` — unit tests for `LayerSelector` up/down transitions +- `tools/go_sfu/participant.go` — REMB parsing in `readRTCPLoop()`, `selectedLayers` +- `tools/go_sfu/sfu.go` — layer-filtered forwarding, `ensureLayerSelector`, SSRC rewriting, `maxActiveLayer` tracking + +## SFU Transport-CC Feedback + +The SFU generates RTCP transport-cc feedback (type 205, FMT 15) per sender every 100ms. This provides the sender's GCC (Google Congestion Control) with packet arrival data, enabling BWE ramp-up so the encoder produces higher simulcast layers. + +The feedback reflects actual (or simulated) packet arrivals — if ingress network simulation drops packets, the feedback reports them as missing, causing the sender's GCC to reduce bitrate. + +### How It Works +1. Each incoming RTP packet is parsed for the transport-wide sequence number (header extension ID 3, one-byte RFC 5285 format) +2. `TransportCCGenerator.RecordArrival(twccSeq)` records the arrival time +3. Every 100ms, `emitFeedback()` builds an `rtcp.TransportLayerCC` packet with `PacketChunks` (run-length or status-vector encoding) and `RecvDeltas` (250µs units) +4. The feedback is marshalled, encrypted via SRTCP, and sent to the sender +5. The sender's `Call::Receiver::DeliverRtcpPacket()` feeds it to the GCC via `GroupNetworkManager::OnRtcpPacketReceived_n` → `_call->Receiver()->DeliverRtcpPacket()` + +### Current Status +Transport-cc feedback is working: the SFU records ~60-70 arrivals per first 100ms tick, generates feedback packets (32-128 bytes), and the sender receives them. The GCC ramps from the 400kbps start bitrate to produce layer 1 (640x360). Full ramp to layer 2 (1280x720, needs ~1Mbps) requires further investigation — the GCC may need probing support or the `adjustBitratePreferences` max_bitrate_bps of 1052kbps may be a bottleneck. + +### Key Files +- `tools/go_sfu/twcc.go` — `TransportCCGenerator`, `parseTWCCSeq` (RTP header extension parser) + +## SFU Network Simulation + +Per-client network simulation with independent ingress (from client) and egress (to client) simulators. Each direction has: delay, jitter, packet loss, and bandwidth cap (token bucket). + +```bash +# Configure via CGo: GoSfu_SetNetworkParams(handle, participantID, direction, delayMs, jitterMs, dropRate, bandwidthBps) +# direction: 0 = ingress, 1 = egress + +# Network scenario test (4 phases: uncapped -> 80k -> 200k -> uncapped) +./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 30 --network-scenario step-down-up +``` + +### Key Files +- `tools/go_sfu/network_sim.go` — `NetworkSimulator` (token bucket, delay, jitter, drop) +- `tools/go_sfu/participant.go` — `ingressSim`, `egressSim` on each `Participant` diff --git a/tools/go_sfu/bandwidth.go b/tools/go_sfu/bandwidth.go new file mode 100644 index 0000000000..47b3e1610f --- /dev/null +++ b/tools/go_sfu/bandwidth.go @@ -0,0 +1,475 @@ +package main + +import ( + "sync" + "time" +) + +// --- Bandwidth Estimation --- + +const ( + ewmaAlpha = 0.3 + safetyFactor = 0.85 + stalenessTTL = 5 * time.Second +) + +// BandwidthEstimator maintains an EWMA-smoothed REMB estimate for a receiver. +type BandwidthEstimator struct { + mu sync.Mutex + lastREMBBps float64 + smoothedBps float64 + lastREMBAt time.Time +} + +// OnREMB feeds a new REMB value (in bits per second) into the estimator. +func (e *BandwidthEstimator) OnREMB(bps float64) { + e.mu.Lock() + defer e.mu.Unlock() + e.lastREMBBps = bps + e.lastREMBAt = time.Now() + if e.smoothedBps == 0 { + e.smoothedBps = bps + } else { + e.smoothedBps = ewmaAlpha*bps + (1-ewmaAlpha)*e.smoothedBps + } +} + +// EffectiveBps returns the safe bandwidth estimate in bps. +// Returns -1 if the estimate is stale (no REMB for stalenessTTL). +func (e *BandwidthEstimator) EffectiveBps() float64 { + e.mu.Lock() + defer e.mu.Unlock() + if e.lastREMBAt.IsZero() || time.Since(e.lastREMBAt) > stalenessTTL { + return -1 + } + return e.smoothedBps * safetyFactor +} + +// SmoothedBps returns the raw EWMA value (for logging). +func (e *BandwidthEstimator) SmoothedBps() float64 { + e.mu.Lock() + defer e.mu.Unlock() + return e.smoothedBps +} + +// LastREMBBps returns the last raw REMB value (for logging). +func (e *BandwidthEstimator) LastREMBBps() float64 { + e.mu.Lock() + defer e.mu.Unlock() + return e.lastREMBBps +} + +// --- Layer Bitrate Model --- + +// LayerBitrate holds the thresholds for one simulcast layer. +type LayerBitrate struct { + Nominal float64 // typical sustained bitrate (bps) + UpThresh float64 // effective BW must exceed this to upswitch TO this layer + DownThresh float64 // effective BW must drop below this to downswitch FROM this layer +} + +// layerBitrates defines the 3 simulcast layers matching tgcalls adjustVideoSendParams(). +// Layer 0 has no downThresh (always viable) and no upThresh (start here). +var layerBitrates = [3]LayerBitrate{ + {Nominal: 60_000, UpThresh: 0, DownThresh: 0}, // layer 0: 160x90 + {Nominal: 110_000, UpThresh: 132_000, DownThresh: 77_000}, // layer 1: 320x180 + {Nominal: 900_000, UpThresh: 1_080_000, DownThresh: 630_000}, // layer 2: 640x360 +} + +// --- RTX Ring Buffer --- + +// RtxEntry stores one video RTP packet for potential retransmission as RTX padding. +type RtxEntry struct { + Payload []byte + SeqNum uint16 + Timestamp uint32 +} + +// RtxRingBuffer is a per-sender circular buffer of recent video RTP packets. +type RtxRingBuffer struct { + mu sync.Mutex + entries []RtxEntry + head int + count int + cap int +} + +// NewRtxRingBuffer creates a ring buffer with the given capacity. +func NewRtxRingBuffer(capacity int) *RtxRingBuffer { + return &RtxRingBuffer{ + entries: make([]RtxEntry, capacity), + cap: capacity, + } +} + +// Push adds a video RTP packet to the ring buffer. +// payload is copied so the caller can reuse their buffer. +func (r *RtxRingBuffer) Push(payload []byte, seqNum uint16, timestamp uint32) { + r.mu.Lock() + defer r.mu.Unlock() + entry := &r.entries[r.head] + if cap(entry.Payload) >= len(payload) { + entry.Payload = entry.Payload[:len(payload)] + } else { + entry.Payload = make([]byte, len(payload)) + } + copy(entry.Payload, payload) + entry.SeqNum = seqNum + entry.Timestamp = timestamp + r.head = (r.head + 1) % r.cap + if r.count < r.cap { + r.count++ + } +} + +// Get returns up to n most recent packets (oldest first). +func (r *RtxRingBuffer) Get(n int) []RtxEntry { + r.mu.Lock() + defer r.mu.Unlock() + if n > r.count { + n = r.count + } + if n == 0 { + return nil + } + result := make([]RtxEntry, n) + start := (r.head - r.count + r.cap) % r.cap // oldest entry + readFrom := (start + r.count - n + r.cap) % r.cap // start of the n most recent + for i := 0; i < n; i++ { + idx := (readFrom + i) % r.cap + src := &r.entries[idx] + entry := RtxEntry{ + Payload: make([]byte, len(src.Payload)), + SeqNum: src.SeqNum, + Timestamp: src.Timestamp, + } + copy(entry.Payload, src.Payload) + result[i] = entry + } + return result +} + +// rtxEncapsulate wraps an original RTP payload into an RTX packet payload per RFC 4588. +// The RTX payload is: [2-byte original sequence number] + [original RTP payload (after header)]. +// The caller is responsible for setting the RTX SSRC and incrementing RTX sequence number +// on the outer RTP header. +func rtxEncapsulate(originalPayload []byte, originalSeqNum uint16) []byte { + out := make([]byte, 2+len(originalPayload)) + out[0] = byte(originalSeqNum >> 8) + out[1] = byte(originalSeqNum) + copy(out[2:], originalPayload) + return out +} + +// --- Layer Selector State Machine --- + +type selectorState int + +const ( + stateStable selectorState = iota + stateProbingUp + stateGraceDown +) + +func (s selectorState) String() string { + switch s { + case stateStable: + return "STABLE" + case stateProbingUp: + return "PROBING_UP" + case stateGraceDown: + return "GRACE_DOWN" + default: + return "UNKNOWN" + } +} + +const ( + probeDuration = 2 * time.Second + graceDownTimeout = 500 * time.Millisecond + cooldownDuration = 5 * time.Second + tickInterval = 100 * time.Millisecond +) + +// LayerSelectorCallbacks provides the hooks the state machine needs into the SFU. +type LayerSelectorCallbacks struct { + // GetEffectiveBW returns the receiver's current effective bandwidth (bps), or -1 if stale. + GetEffectiveBW func() float64 + // SetSelectedLayer updates the forwarding layer for this (receiver, sender) pair. + SetSelectedLayer func(layer int) + // SendPLI sends a PLI to the sender for the given SSRC. + SendPLI func(ssrc uint32) + // GetSenderVideoLayers returns the sender's simulcast layers. + GetSenderVideoLayers func() []SimulcastLayer + // GetRtxBuffer returns the sender's RTX ring buffer. + GetRtxBuffer func() *RtxRingBuffer + // SendRtxPadding sends an RTX padding packet to the receiver. + // rtxSSRC is the FID SSRC, seqNum is the RTX sequence number. + SendRtxPadding func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) + // Log emits a log message. + Log func(level string, format string, args ...interface{}) +} + +// LayerSelector manages the state machine for one (receiver, sender) pair. +type LayerSelector struct { + mu sync.Mutex + receiverID int + senderID int + currentLayer int + maxLayer int // max layer the receiver requested + state selectorState + callbacks LayerSelectorCallbacks + + // Probing state + probeTarget int // layer we're probing toward + probeStartTime time.Time + probeRtxSeq uint16 // incrementing RTX sequence number for padding + + // Grace-down state + graceStartTime time.Time + + // Cooldown + lastSwitchTime time.Time + + // Control + stopCh chan struct{} + done chan struct{} +} + +// NewLayerSelector creates and starts a new LayerSelector. +// initialLayer is the layer to start forwarding (typically = requestedLayer). +func NewLayerSelector(receiverID, senderID, initialLayer, maxLayer int, cb LayerSelectorCallbacks) *LayerSelector { + ls := &LayerSelector{ + receiverID: receiverID, + senderID: senderID, + currentLayer: initialLayer, + maxLayer: maxLayer, + state: stateStable, + callbacks: cb, + stopCh: make(chan struct{}), + done: make(chan struct{}), + } + go ls.run() + return ls +} + +// Stop terminates the selector's tick loop. +func (ls *LayerSelector) Stop() { + close(ls.stopCh) + <-ls.done +} + +// SetMaxLayer updates the maximum layer the receiver wants (from ReceiverVideoConstraints). +func (ls *LayerSelector) SetMaxLayer(maxLayer int) { + ls.mu.Lock() + defer ls.mu.Unlock() + ls.maxLayer = maxLayer + // If current layer exceeds new max, downswitch immediately. + if ls.currentLayer > maxLayer { + ls.switchLayer(maxLayer) + } +} + +// OnMaxActiveLayerIncreased is called when the sender starts producing a +// higher simulcast layer than previously observed. If the BW estimate is +// stale (no REMB arriving — common when clients use transport-cc exclusively +// and the SFU hasn't generated REMB), upshift immediately up to maxLayer so +// the receiver gets the best available layer. When REMB is fresh, the state +// machine is in charge and this is a no-op. +func (ls *LayerSelector) OnMaxActiveLayerIncreased(maxActive int) { + ls.mu.Lock() + defer ls.mu.Unlock() + if ls.callbacks.GetEffectiveBW() >= 0 { + // BW estimate available — state machine decides. + return + } + target := maxActive + if target > ls.maxLayer { + target = ls.maxLayer + } + if target > ls.currentLayer { + ls.switchLayer(target) + } +} + +// CurrentLayer returns the currently selected layer. +func (ls *LayerSelector) CurrentLayer() int { + ls.mu.Lock() + defer ls.mu.Unlock() + return ls.currentLayer +} + +func (ls *LayerSelector) run() { + defer close(ls.done) + ticker := time.NewTicker(tickInterval) + defer ticker.Stop() + + for { + select { + case <-ls.stopCh: + return + case <-ticker.C: + ls.tick() + } + } +} + +func (ls *LayerSelector) tick() { + ls.mu.Lock() + defer ls.mu.Unlock() + + effectiveBW := ls.callbacks.GetEffectiveBW() + if effectiveBW < 0 { + // Stale estimate — do nothing. + return + } + + switch ls.state { + case stateStable: + ls.tickStable(effectiveBW) + case stateProbingUp: + ls.tickProbingUp(effectiveBW) + case stateGraceDown: + ls.tickGraceDown(effectiveBW) + } +} + +func (ls *LayerSelector) tickStable(effectiveBW float64) { + // Check for upswitch opportunity. + nextLayer := ls.currentLayer + 1 + if nextLayer <= ls.maxLayer && nextLayer <= 2 { + if !ls.inCooldown() && effectiveBW > layerBitrates[nextLayer].UpThresh { + ls.state = stateProbingUp + ls.probeTarget = nextLayer + ls.probeStartTime = time.Now() + ls.callbacks.Log("INFO", "Participant %d<-%d: STABLE->PROBING_UP (BW=%.0fkbps, target=layer%d@%.0fkbps)", + ls.receiverID, ls.senderID, effectiveBW/1000, nextLayer, layerBitrates[nextLayer].UpThresh/1000) + return + } + } + + // Check for downswitch need. + if ls.currentLayer > 0 { + if effectiveBW < layerBitrates[ls.currentLayer].DownThresh { + ls.state = stateGraceDown + ls.graceStartTime = time.Now() + ls.callbacks.Log("INFO", "Participant %d<-%d: STABLE->GRACE_DOWN (BW=%.0fkbps, thresh=%.0fkbps)", + ls.receiverID, ls.senderID, effectiveBW/1000, layerBitrates[ls.currentLayer].DownThresh/1000) + return + } + } +} + +func (ls *LayerSelector) tickProbingUp(effectiveBW float64) { + elapsed := time.Since(ls.probeStartTime) + + // Abort if bandwidth dropped below current layer's nominal bitrate. + if effectiveBW < layerBitrates[ls.currentLayer].Nominal { + ls.state = stateStable + ls.lastSwitchTime = time.Now() // enter cooldown + ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (abort, BW=%.0fkbps < nominal=%.0fkbps)", + ls.receiverID, ls.senderID, effectiveBW/1000, layerBitrates[ls.currentLayer].Nominal/1000) + return + } + + // Probe complete — switch up. + if elapsed >= probeDuration { + if effectiveBW > layerBitrates[ls.probeTarget].Nominal { + ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (success, switching to layer %d)", + ls.receiverID, ls.senderID, ls.probeTarget) + ls.switchLayer(ls.probeTarget) + return + } + // BW not sufficient at end of probe — abort. + ls.state = stateStable + ls.lastSwitchTime = time.Now() + ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (probe done but BW=%.0fkbps insufficient)", + ls.receiverID, ls.senderID, effectiveBW/1000) + return + } + + // Send RTX padding during probe. + ls.sendProbePadding(elapsed) +} + +func (ls *LayerSelector) tickGraceDown(effectiveBW float64) { + // If bandwidth recovered, cancel grace period. + if effectiveBW >= layerBitrates[ls.currentLayer].DownThresh { + ls.state = stateStable + ls.callbacks.Log("INFO", "Participant %d<-%d: GRACE_DOWN->STABLE (recovered, BW=%.0fkbps)", + ls.receiverID, ls.senderID, effectiveBW/1000) + return + } + + // Grace period expired — downswitch. + if time.Since(ls.graceStartTime) >= graceDownTimeout { + targetLayer := ls.currentLayer - 1 + if targetLayer < 0 { + targetLayer = 0 + } + ls.callbacks.Log("INFO", "Participant %d<-%d: GRACE_DOWN->STABLE (downswitch to layer %d)", + ls.receiverID, ls.senderID, targetLayer) + ls.switchLayer(targetLayer) + } +} + +func (ls *LayerSelector) switchLayer(newLayer int) { + oldLayer := ls.currentLayer + ls.currentLayer = newLayer + ls.state = stateStable + ls.lastSwitchTime = time.Now() + ls.callbacks.SetSelectedLayer(newLayer) + + // Request keyframe at the new layer. + layers := ls.callbacks.GetSenderVideoLayers() + if newLayer < len(layers) { + ls.callbacks.SendPLI(layers[newLayer].SSRC) + ls.callbacks.Log("INFO", "Participant %d<-%d: switched layer %d->%d (PLI sent for SSRC=%d)", + ls.receiverID, ls.senderID, oldLayer, newLayer, layers[newLayer].SSRC) + } +} + +func (ls *LayerSelector) inCooldown() bool { + return !ls.lastSwitchTime.IsZero() && time.Since(ls.lastSwitchTime) < cooldownDuration +} + +func (ls *LayerSelector) sendProbePadding(elapsed time.Duration) { + // Calculate target padding rate: ramp from 0 to gap over probeDuration. + gap := layerBitrates[ls.probeTarget].Nominal - layerBitrates[ls.currentLayer].Nominal + progress := float64(elapsed) / float64(probeDuration) + targetBps := gap * progress + + // How many bytes to send in this 100ms tick. + bytesPerTick := targetBps / 8 / (float64(time.Second) / float64(tickInterval)) + + rtxBuf := ls.callbacks.GetRtxBuffer() + if rtxBuf == nil { + return + } + + // Pull packets from the ring buffer to fill the target bytes. + entries := rtxBuf.Get(20) // enough for one tick + if len(entries) == 0 { + return + } + + layers := ls.callbacks.GetSenderVideoLayers() + if ls.currentLayer >= len(layers) { + return + } + rtxSSRC := layers[ls.currentLayer].FidSSRC + if rtxSSRC == 0 { + return + } + + var sentBytes float64 + entryIdx := 0 + for sentBytes < bytesPerTick && entryIdx < len(entries) { + entry := entries[entryIdx] + entryIdx++ + rtxPayload := rtxEncapsulate(entry.Payload, entry.SeqNum) + ls.probeRtxSeq++ + ls.callbacks.SendRtxPadding(rtxPayload, rtxSSRC, ls.probeRtxSeq, entry.Timestamp) + sentBytes += float64(len(rtxPayload)) + } +} diff --git a/tools/go_sfu/bandwidth_test.go b/tools/go_sfu/bandwidth_test.go new file mode 100644 index 0000000000..e8ce371b8e --- /dev/null +++ b/tools/go_sfu/bandwidth_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// mockCallbacks is a test harness for driving the LayerSelector. BW is +// atomic so the selector's run() goroutine can read while the test writes. +type mockCallbacks struct { + bw atomic.Int64 // current effective bandwidth in bps; negative = stale + layers []SimulcastLayer + selectedLayer atomic.Int32 + pliCount atomic.Int32 + probePaddings atomic.Int32 + + logMu sync.Mutex + logBuf []string +} + +func newMockCallbacks(layers []SimulcastLayer, initialBW float64) *mockCallbacks { + m := &mockCallbacks{layers: layers} + m.bw.Store(int64(initialBW)) + m.selectedLayer.Store(-1) + return m +} + +func (m *mockCallbacks) setBW(bps float64) { m.bw.Store(int64(bps)) } + +func (m *mockCallbacks) currentSelected() int { return int(m.selectedLayer.Load()) } + +func (m *mockCallbacks) toCallbacks() LayerSelectorCallbacks { + return LayerSelectorCallbacks{ + GetEffectiveBW: func() float64 { + v := float64(m.bw.Load()) + if v < 0 { + return -1 + } + return v + }, + SetSelectedLayer: func(layer int) { + m.selectedLayer.Store(int32(layer)) + }, + SendPLI: func(ssrc uint32) { + m.pliCount.Add(1) + }, + GetSenderVideoLayers: func() []SimulcastLayer { + return m.layers + }, + GetRtxBuffer: func() *RtxRingBuffer { + return nil // probing padding no-ops without a buffer + }, + SendRtxPadding: func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) { + m.probePaddings.Add(1) + }, + Log: func(level string, format string, args ...interface{}) { + m.logMu.Lock() + m.logBuf = append(m.logBuf, fmt.Sprintf("["+level+"] "+format, args...)) + m.logMu.Unlock() + }, + } +} + +// waitForLayer polls the selector's currentLayer up to timeout for a change +// to `want`. Returns true if reached, false on timeout. +func waitForLayer(ls *LayerSelector, want int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if ls.CurrentLayer() == want { + return true + } + time.Sleep(20 * time.Millisecond) + } + return false +} + +func testLayers() []SimulcastLayer { + return []SimulcastLayer{ + {SSRC: 1001, FidSSRC: 1002}, + {SSRC: 1003, FidSSRC: 1004}, + {SSRC: 1005, FidSSRC: 1006}, + } +} + +// TestLayerSelectorUpswitch verifies L0 -> L1 -> L2 based on rising BW. +// +// Thresholds (from layerBitrates): +// +// L1 UpThresh = 132 kbps → needs REMB > ~155 kbps (with 0.85 safety factor) +// L2 UpThresh = 1080 kbps → needs REMB > ~1271 kbps +// +// The selector's state machine enforces a 5s cooldown after each switch, so +// the whole test runs in ~8-10 seconds. +func TestLayerSelectorUpswitch(t *testing.T) { + m := newMockCallbacks(testLayers(), 200_000) // > L1 UpThresh + ls := NewLayerSelector(1, 0, 0, 2, m.toCallbacks()) + defer ls.Stop() + + // L0 -> L1: should enter PROBING_UP within 150ms (one tick), then + // complete the 2s probe and switch to L1. + if !waitForLayer(ls, 1, 3*time.Second) { + t.Fatalf("L0->L1 upswitch timed out; currentLayer=%d selected=%d", ls.CurrentLayer(), m.currentSelected()) + } + if got := m.currentSelected(); got != 1 { + t.Fatalf("after L1 upswitch, SetSelectedLayer was not called with 1 (got %d)", got) + } + if pli := m.pliCount.Load(); pli < 1 { + t.Fatalf("expected at least 1 PLI on layer switch, got %d", pli) + } + + // L1 -> L2: raise BW above L2 UpThresh. Wait out the 5s cooldown and + // then the 2s probe (total ~7-8s). + m.setBW(1_500_000) + if !waitForLayer(ls, 2, 10*time.Second) { + t.Fatalf("L1->L2 upswitch timed out; currentLayer=%d", ls.CurrentLayer()) + } + if got := m.currentSelected(); got != 2 { + t.Fatalf("after L2 upswitch, SetSelectedLayer was not called with 2 (got %d)", got) + } +} + +// TestLayerSelectorDownswitch verifies L2 -> L1 -> L0 based on falling BW. +// Starts the selector pre-positioned at L2 by setting its state directly +// via `switchLayer`-equivalent initial-layer argument, then drives BW down. +// +// Thresholds: +// +// L2 DownThresh = 630 kbps → needs REMB < ~741 kbps +// L1 DownThresh = 77 kbps → needs REMB < ~91 kbps +// +// Downswitches are governed by a 500ms grace period, no cooldown, so this +// test runs in ~1.5 seconds. +func TestLayerSelectorDownswitch(t *testing.T) { + m := newMockCallbacks(testLayers(), 1_500_000) // high BW, at L2 + ls := NewLayerSelector(1, 0, 2, 2, m.toCallbacks()) + defer ls.Stop() + + // Drop BW below L2 downswitch threshold. Effective = 500k * 0.85 = 425k + // is NOT below 630k effective threshold directly. Use 600k raw so + // effective = 510k, well below 630k. + m.setBW(600_000) + if !waitForLayer(ls, 1, 2*time.Second) { + t.Fatalf("L2->L1 downswitch timed out; currentLayer=%d", ls.CurrentLayer()) + } + if got := m.currentSelected(); got != 1 { + t.Fatalf("after L1 downswitch, SetSelectedLayer was not called with 1 (got %d)", got) + } + + // Drop below L1 downswitch threshold (77k effective → raw < 91k). + // Use 50k raw → effective 42k. + m.setBW(50_000) + if !waitForLayer(ls, 0, 2*time.Second) { + t.Fatalf("L1->L0 downswitch timed out; currentLayer=%d", ls.CurrentLayer()) + } + if got := m.currentSelected(); got != 0 { + t.Fatalf("after L0 downswitch, SetSelectedLayer was not called with 0 (got %d)", got) + } +} + +// TestLayerSelectorGraceDownRecovery verifies that a transient BW dip that +// recovers within the 500ms grace window does NOT cause a downswitch. +func TestLayerSelectorGraceDownRecovery(t *testing.T) { + m := newMockCallbacks(testLayers(), 1_500_000) + ls := NewLayerSelector(1, 0, 2, 2, m.toCallbacks()) + defer ls.Stop() + + // Dip below downthresh, then recover before grace expires. + m.setBW(500_000) // below L2 downthresh + time.Sleep(300 * time.Millisecond) + m.setBW(1_500_000) // recovered + time.Sleep(500 * time.Millisecond) + + if got := ls.CurrentLayer(); got != 2 { + t.Fatalf("transient dip should not have downswitched; currentLayer=%d", got) + } +} + +// TestLayerSelectorStaleBW verifies that with no REMB data (BW=-1), the +// state machine does not transition. +func TestLayerSelectorStaleBW(t *testing.T) { + m := newMockCallbacks(testLayers(), -1) // stale + ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) + defer ls.Stop() + + time.Sleep(1 * time.Second) + if got := ls.CurrentLayer(); got != 1 { + t.Fatalf("stale BW should not trigger a transition; currentLayer=%d", got) + } +} + +// TestLayerSelectorOnMaxActiveLayerIncreasedWhenStale verifies the fallback +// path: when BW is stale (clients don't send REMB), discovery of a higher +// active layer from the sender causes an immediate upswitch. +func TestLayerSelectorOnMaxActiveLayerIncreasedWhenStale(t *testing.T) { + m := newMockCallbacks(testLayers(), -1) + ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) + defer ls.Stop() + + // Nothing has happened yet. + if got := ls.CurrentLayer(); got != 1 { + t.Fatalf("unexpected initial layer %d", got) + } + + // Sender starts producing L2. With stale BW, we should upshift + // immediately up to the receiver's requested maxLayer. + ls.OnMaxActiveLayerIncreased(2) + if got := ls.CurrentLayer(); got != 2 { + t.Fatalf("expected upshift to L2 on maxActive increase with stale BW; got %d", got) + } + if got := m.currentSelected(); got != 2 { + t.Fatalf("SetSelectedLayer should have been called with 2; got %d", got) + } +} + +// TestLayerSelectorOnMaxActiveLayerIncreasedWhenFresh verifies that when BW +// is fresh, OnMaxActiveLayerIncreased is a no-op — the state machine is in +// charge of layer selection. +func TestLayerSelectorOnMaxActiveLayerIncreasedWhenFresh(t *testing.T) { + m := newMockCallbacks(testLayers(), 200_000) // fresh, enough for L1 only + ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) + defer ls.Stop() + + ls.OnMaxActiveLayerIncreased(2) + if got := ls.CurrentLayer(); got != 1 { + t.Fatalf("fresh BW should leave state machine in charge; current=%d", got) + } +} + +// TestLayerSelectorRespectsMaxLayer verifies that upswitches never exceed +// the receiver's requested maxLayer. +func TestLayerSelectorRespectsMaxLayer(t *testing.T) { + m := newMockCallbacks(testLayers(), 2_000_000) // way more than needed for L2 + ls := NewLayerSelector(1, 0, 0, 1, m.toCallbacks()) + defer ls.Stop() + + // Wait long enough for an L0->L1 upswitch (~2.2s). Then wait past the + // cooldown (5s) plus another probe window (2s) to ensure the selector + // does NOT attempt to probe beyond maxLayer=1. + if !waitForLayer(ls, 1, 3*time.Second) { + t.Fatalf("L0->L1 upswitch timed out") + } + time.Sleep(8 * time.Second) + if got := ls.CurrentLayer(); got != 1 { + t.Fatalf("selector upshifted beyond maxLayer=1; got %d", got) + } +} diff --git a/tools/go_sfu/go.mod b/tools/go_sfu/go.mod new file mode 100644 index 0000000000..0cdde5f9a6 --- /dev/null +++ b/tools/go_sfu/go.mod @@ -0,0 +1,27 @@ +module github.com/nicegram/AltTgCalls/tools/go_sfu + +go 1.24.2 + +require ( + github.com/pion/datachannel v1.5.10 + github.com/pion/dtls/v3 v3.0.6 + github.com/pion/ice/v4 v4.0.7 + github.com/pion/logging v0.2.3 + github.com/pion/rtcp v1.2.15 + github.com/pion/sctp v1.8.37 + github.com/pion/srtp/v3 v3.0.5 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtp v1.8.17 // indirect + github.com/pion/stun/v3 v3.0.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/pion/turn/v4 v4.0.0 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect +) diff --git a/tools/go_sfu/go.sum b/tools/go_sfu/go.sum new file mode 100644 index 0000000000..5c1611482a --- /dev/null +++ b/tools/go_sfu/go.sum @@ -0,0 +1,44 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= +github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= +github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= +github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= +github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= +github.com/pion/rtp v1.8.17 h1:CFhaPN8Ikt9Sk7B3pic0kfwVia2dUMEtPSL34Gvihjw= +github.com/pion/rtp v1.8.17/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= +github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo= +github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/go_sfu/mux.go b/tools/go_sfu/mux.go new file mode 100644 index 0000000000..2fb31b4853 --- /dev/null +++ b/tools/go_sfu/mux.go @@ -0,0 +1,239 @@ +package main + +import ( + "fmt" + "io" + "net" + "sync" + "time" +) + +const ( + muxReadBufSize = 8192 + muxChanBufSize = 256 +) + +// isDTLS returns true if the first byte indicates a DTLS record (RFC 7983: 20–63). +func isDTLS(b byte) bool { + return b >= 20 && b <= 63 +} + +// isRTPOrRTCP returns true if the first byte indicates an RTP/RTCP packet (RFC 7983: 128–191). +func isRTPOrRTCP(b byte) bool { + return b >= 128 && b <= 191 +} + +// isRTCP returns true if the packet is RTCP (not RTP) per RFC 5761 Section 4. +// RTCP packet types (byte[1]) are 200-211. RTP with Marker=1 and dynamic PT >= 96 +// gives byte[1] >= 224, so we use byte[1] >= 200 && byte[1] < 224 to exclude RTP. +// In SRTCP the fixed header is unencrypted, so byte[1] is readable. +func isRTCP(pkt []byte) bool { + return len(pkt) >= 2 && pkt[1] >= 200 && pkt[1] < 224 +} + +// PacketDemux reads from a net.Conn and routes packets to separate DTLS, +// SRTP (RTP only), and RTCP channels based on RFC 7983 first-byte classification +// and RTP/RTCP payload type demux. +type PacketDemux struct { + conn net.Conn + dtlsCh chan []byte + srtpCh chan []byte + rtcpCh chan []byte + once sync.Once + closed chan struct{} + label string +} + +func (d *PacketDemux) logf(format string, args ...interface{}) { + fmt.Printf("[demux-%s] %s\n", d.label, fmt.Sprintf(format, args...)) +} + +// NewPacketDemux creates a PacketDemux and starts the read loop goroutine. +func NewPacketDemux(conn net.Conn, label string) *PacketDemux { + d := &PacketDemux{ + conn: conn, + dtlsCh: make(chan []byte, muxChanBufSize), + srtpCh: make(chan []byte, muxChanBufSize), + rtcpCh: make(chan []byte, muxChanBufSize), + closed: make(chan struct{}), + label: label, + } + go d.readLoop() + return d +} + +func (d *PacketDemux) readLoop() { + buf := make([]byte, muxReadBufSize) + dtlsCount := 0 + srtpCount := 0 + rtcpCount := 0 + otherCount := 0 + for { + n, err := d.conn.Read(buf) + if err != nil { + d.Close() + return + } + if n == 0 { + continue + } + pkt := make([]byte, n) + copy(pkt, buf[:n]) + + switch { + case isDTLS(pkt[0]): + dtlsCount++ + if dtlsCount <= 5 { + d.logf("DTLS packet #%d: %d bytes (first byte: 0x%02x)", dtlsCount, n, pkt[0]) + } + select { + case d.dtlsCh <- pkt: + default: + d.logf("DTLS channel full, dropping packet") + } + case isRTPOrRTCP(pkt[0]): + if isRTCP(pkt) { + rtcpCount++ + if rtcpCount <= 3 { + d.logf("RTCP packet #%d: %d bytes (type byte: 0x%02x)", rtcpCount, n, pkt[1]) + } + select { + case d.rtcpCh <- pkt: + default: + // drop if channel full + } + } else { + srtpCount++ + if srtpCount == 1 { + d.logf("First SRTP packet: %d bytes", n) + } + select { + case d.srtpCh <- pkt: + default: + // drop if channel full + } + } + default: + otherCount++ + if otherCount <= 3 { + d.logf("Other packet: %d bytes (first byte: 0x%02x)", n, pkt[0]) + } + } + } +} + +// Close shuts down the demuxer and the underlying connection. +func (d *PacketDemux) Close() error { + var err error + d.once.Do(func() { + close(d.closed) + err = d.conn.Close() + }) + return err +} + +// DTLSEndpoint returns a net.Conn that yields only DTLS packets. +func (d *PacketDemux) DTLSEndpoint() net.Conn { + return &demuxEndpoint{demux: d, ch: d.dtlsCh} +} + +// SRTPEndpoint returns a net.Conn that yields only SRTP (RTP) packets. +// RTCP packets are routed to RTCPChannel() instead. +func (d *PacketDemux) SRTPEndpoint() net.Conn { + return &demuxEndpoint{demux: d, ch: d.srtpCh} +} + +// RTCPChannel returns a channel that receives raw encrypted SRTCP packets. +// These must be decrypted externally (not via SessionSRTP which only handles RTP). +func (d *PacketDemux) RTCPChannel() <-chan []byte { + return d.rtcpCh +} + +// demuxEndpoint implements net.Conn for a single demux channel. +type demuxEndpoint struct { + demux *PacketDemux + ch chan []byte + mu sync.Mutex + leftover []byte +} + +func (e *demuxEndpoint) Read(b []byte) (int, error) { + e.mu.Lock() + if len(e.leftover) > 0 { + n := copy(b, e.leftover) + e.leftover = e.leftover[n:] + if len(e.leftover) == 0 { + e.leftover = nil + } + e.mu.Unlock() + return n, nil + } + e.mu.Unlock() + + select { + case <-e.demux.closed: + return 0, io.EOF + case pkt, ok := <-e.ch: + if !ok { + return 0, io.EOF + } + n := copy(b, pkt) + if n < len(pkt) { + e.mu.Lock() + e.leftover = pkt[n:] + e.mu.Unlock() + } + return n, nil + } +} + +func (e *demuxEndpoint) Write(b []byte) (int, error) { + return e.demux.conn.Write(b) +} + +func (e *demuxEndpoint) Close() error { + return e.demux.Close() +} + +func (e *demuxEndpoint) LocalAddr() net.Addr { + return e.demux.conn.LocalAddr() +} + +func (e *demuxEndpoint) RemoteAddr() net.Addr { + return e.demux.conn.RemoteAddr() +} + +func (e *demuxEndpoint) SetDeadline(t time.Time) error { + return e.demux.conn.SetDeadline(t) +} + +func (e *demuxEndpoint) SetReadDeadline(t time.Time) error { + return e.demux.conn.SetReadDeadline(t) +} + +func (e *demuxEndpoint) SetWriteDeadline(t time.Time) error { + return e.demux.conn.SetWriteDeadline(t) +} + +// connToPacketConn wraps a net.Conn into a net.PacketConn. +// It is used to adapt a demuxEndpoint for pion/dtls.Server(), which +// requires net.PacketConn. Since the endpoint is already bound to a +// single peer, ReadFrom returns the conn's RemoteAddr and WriteTo ignores +// the addr parameter. +type connToPacketConn struct { + net.Conn +} + +// WrapAsPacketConn adapts a net.Conn to net.PacketConn. +func WrapAsPacketConn(c net.Conn) net.PacketConn { + return &connToPacketConn{Conn: c} +} + +func (c *connToPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, err := c.Conn.Read(b) + return n, c.Conn.RemoteAddr(), err +} + +func (c *connToPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { + return c.Conn.Write(b) +} diff --git a/tools/go_sfu/network_sim.go b/tools/go_sfu/network_sim.go new file mode 100644 index 0000000000..b1b597dd05 --- /dev/null +++ b/tools/go_sfu/network_sim.go @@ -0,0 +1,128 @@ +package main + +import ( + "math/rand" + "sync" + "time" +) + +// NetworkSimulator models a uni-directional network pipe with delay, jitter, +// packet loss, and bandwidth cap (token bucket). +type NetworkSimulator struct { + mu sync.Mutex + delayMs int + jitterMs int + dropRate float64 + bandwidthBps int64 + + // Token bucket for bandwidth cap. + tokens float64 // available tokens (bits) + maxTokens float64 // max tokens = 200ms worth of bandwidth + lastRefill time.Time + rng *rand.Rand + + closed bool +} + +// NewNetworkSimulator creates a simulator with no simulation (passthrough). +func NewNetworkSimulator() *NetworkSimulator { + return &NetworkSimulator{ + lastRefill: time.Now(), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// SetParams reconfigures the simulator at runtime. Thread-safe. +func (ns *NetworkSimulator) SetParams(delayMs, jitterMs int, dropRate float64, bandwidthBps int64) { + ns.mu.Lock() + defer ns.mu.Unlock() + ns.delayMs = delayMs + ns.jitterMs = jitterMs + ns.dropRate = dropRate + ns.bandwidthBps = bandwidthBps + if bandwidthBps > 0 { + ns.maxTokens = float64(bandwidthBps) * 0.2 // 200ms buffer + if ns.tokens > ns.maxTokens { + ns.tokens = ns.maxTokens + } + } else { + ns.maxTokens = 0 + ns.tokens = 0 + } +} + +// Send processes a packet through the simulator. deliverFn is called +// (possibly asynchronously) after simulation. The packet bytes are copied +// if delivery is deferred. +func (ns *NetworkSimulator) Send(pkt []byte, deliverFn func([]byte)) { + ns.mu.Lock() + if ns.closed { + ns.mu.Unlock() + return + } + + // Drop check. + if ns.dropRate > 0 && ns.rng.Float64() < ns.dropRate { + ns.mu.Unlock() + return + } + + // Bandwidth cap: token bucket. + if ns.bandwidthBps > 0 { + ns.refillTokens() + cost := float64(len(pkt)) * 8 + if ns.tokens < cost { + // Queue full / no tokens — tail drop. + ns.mu.Unlock() + return + } + ns.tokens -= cost + } + + // Calculate delay. + delayMs := ns.delayMs + if ns.jitterMs > 0 { + delayMs += ns.rng.Intn(2*ns.jitterMs+1) - ns.jitterMs + if delayMs < 0 { + delayMs = 0 + } + } + ns.mu.Unlock() + + if delayMs == 0 { + deliverFn(pkt) + return + } + + // Copy packet for deferred delivery. + pktCopy := make([]byte, len(pkt)) + copy(pktCopy, pkt) + time.AfterFunc(time.Duration(delayMs)*time.Millisecond, func() { + deliverFn(pktCopy) + }) +} + +// Close stops the simulator. Pending delayed packets may still fire. +func (ns *NetworkSimulator) Close() { + ns.mu.Lock() + ns.closed = true + ns.mu.Unlock() +} + +// refillTokens adds tokens based on elapsed time. Must be called with mu held. +func (ns *NetworkSimulator) refillTokens() { + now := time.Now() + elapsed := now.Sub(ns.lastRefill).Seconds() + ns.lastRefill = now + ns.tokens += float64(ns.bandwidthBps) * elapsed + if ns.tokens > ns.maxTokens { + ns.tokens = ns.maxTokens + } +} + +// IsPassthrough returns true if no simulation is configured. +func (ns *NetworkSimulator) IsPassthrough() bool { + ns.mu.Lock() + defer ns.mu.Unlock() + return ns.delayMs == 0 && ns.jitterMs == 0 && ns.dropRate == 0 && ns.bandwidthBps == 0 +} diff --git a/tools/go_sfu/participant.go b/tools/go_sfu/participant.go new file mode 100644 index 0000000000..3dd3a77bc4 --- /dev/null +++ b/tools/go_sfu/participant.go @@ -0,0 +1,637 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "fmt" + "math/big" + "net" + "sync" + "time" + + "github.com/pion/datachannel" + "github.com/pion/dtls/v3" + "github.com/pion/ice/v4" + "github.com/pion/logging" + "github.com/pion/rtcp" + "github.com/pion/sctp" + "github.com/pion/srtp/v3" +) + +// ParticipantConfig holds the client's join parameters extracted from the join payload. +type ParticipantConfig struct { + AudioSSRC uint32 + Ufrag string + Pwd string + Fingerprint string // SHA-256, colon-separated uppercase hex (e.g., "AB:CD:EF:...") +} + +// Participant holds the per-participant transport stack: ICE → DTLS → SRTP + SCTP/DataChannel. +type Participant struct { + ID int + AudioSSRC uint32 + + iceAgent *ice.Agent + iceConn *ice.Conn + + demux *PacketDemux + dtlsConn *dtls.Conn + + srtpSession *srtp.SessionSRTP + srtpWriter *srtp.WriteStreamSRTP + srtpProfile srtp.ProtectionProfile + srtpKeys srtp.SessionKeys // saved for creating SRTCP contexts + + // Separate SRTCP contexts for manual RTCP decrypt/encrypt. + // These are independent from the SessionSRTP used for RTP. + srtcpRemoteCtx *srtp.Context // decrypt SRTCP received from this participant + srtcpLocalCtx *srtp.Context // encrypt SRTCP sent to this participant + srtcpMu sync.Mutex // protects srtcpLocalCtx (single-writer) + + sctpAssoc *sctp.Association + dataChannel *datachannel.DataChannel + + tlsCert tls.Certificate + fingerprint string // SHA-256, colon-separated uppercase hex + localUfrag string + localPwd string + + loggerFactory logging.LoggerFactory + log logging.LeveledLogger + + // Video layer selection: receiver requests which layer to receive from each sender. + videoLayerMu sync.RWMutex + requestedLayers map[int]int // senderID -> layer index + + // Bandwidth estimation from REMB. + bwEstimator *BandwidthEstimator + + // Selected layers: what the SFU actually forwards (set by LayerSelector). + selectedLayerMu sync.RWMutex + selectedLayers map[int]int // senderID -> layer index + onColibriMessage func(participantID int, msg string) // set before Connect(), read from acceptDataChannel goroutine + + // RTCP feedback callback: called when PLI or FIR is received from this participant. + // mediaSSRC is the SSRC the receiver wants a keyframe for. + onRTCPFeedback func(participantID int, mediaSSRC uint32, isFIR bool) + + // Network simulation (delay/jitter/loss/bandwidth cap per direction). + ingressSim *NetworkSimulator + egressSim *NetworkSimulator + + closed chan struct{} + once sync.Once +} + +// NewParticipant creates a new Participant with an ICE agent and self-signed certificate. +// It does NOT start ICE gathering or connection — call GatherCandidates() and Connect() for that. +func NewParticipant(id int, config ParticipantConfig, loggerFactory logging.LoggerFactory) (*Participant, error) { + log := loggerFactory.NewLogger(fmt.Sprintf("participant-%d", id)) + + // Generate self-signed ECDSA P-256 certificate. + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ECDSA key: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) + if err != nil { + return nil, fmt.Errorf("create certificate: %w", err) + } + + tlsCert := tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: privKey, + } + + // Compute SHA-256 fingerprint of the DER certificate. + hash := sha256.Sum256(certDER) + fingerprint := formatFingerprint(hash[:]) + + // Create ICE agent — UDP, host candidates, ICE-lite. + // The tgcalls GroupNetworkManager hardcodes ICEROLE_CONTROLLED for the client, + // so the SFU must be the controlling side (use Dial, not Accept). + // ICE-lite: the SFU passively accepts incoming connectivity checks. + // No remote candidates needed: when the client's STUN binding requests arrive, + // pion creates peer-reflexive candidates automatically. + agent, err := ice.NewAgent(&ice.AgentConfig{ + NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4}, + CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost}, + Lite: true, + IncludeLoopback: true, + IPFilter: func(ip net.IP) bool { + return true // accept all interfaces, including loopback + }, + LoggerFactory: loggerFactory, + }) + if err != nil { + return nil, fmt.Errorf("create ICE agent: %w", err) + } + + localUfrag, localPwd, err := agent.GetLocalUserCredentials() + if err != nil { + _ = agent.Close() + return nil, fmt.Errorf("get local credentials: %w", err) + } + + log.Infof("Created participant %d (SSRC=%d, ufrag=%s, fingerprint=%s)", id, config.AudioSSRC, localUfrag, fingerprint) + + return &Participant{ + ID: id, + AudioSSRC: config.AudioSSRC, + iceAgent: agent, + tlsCert: tlsCert, + fingerprint: fingerprint, + localUfrag: localUfrag, + localPwd: localPwd, + loggerFactory: loggerFactory, + log: log, + requestedLayers: make(map[int]int), + bwEstimator: &BandwidthEstimator{}, + selectedLayers: make(map[int]int), + ingressSim: NewNetworkSimulator(), + egressSim: NewNetworkSimulator(), + closed: make(chan struct{}), + }, nil +} + +// Fingerprint returns the SHA-256 fingerprint of the participant's DTLS certificate. +func (p *Participant) Fingerprint() string { + return p.fingerprint +} + +// LocalUfrag returns the local ICE username fragment. +func (p *Participant) LocalUfrag() string { + return p.localUfrag +} + +// LocalPwd returns the local ICE password. +func (p *Participant) LocalPwd() string { + return p.localPwd +} + +// GatherCandidates triggers ICE gathering and waits for completion. +// Returns the gathered ICE candidates. +func (p *Participant) GatherCandidates() ([]ice.Candidate, error) { + var ( + candidates []ice.Candidate + mu sync.Mutex + done = make(chan struct{}) + ) + + if err := p.iceAgent.OnCandidate(func(c ice.Candidate) { + if c == nil { + // nil candidate signals gathering complete. + close(done) + return + } + mu.Lock() + candidates = append(candidates, c) + mu.Unlock() + }); err != nil { + return nil, fmt.Errorf("set OnCandidate: %w", err) + } + + if err := p.iceAgent.GatherCandidates(); err != nil { + return nil, fmt.Errorf("gather candidates: %w", err) + } + + <-done + + mu.Lock() + defer mu.Unlock() + p.log.Infof("Gathered %d ICE candidates", len(candidates)) + return candidates, nil +} + +// Connect establishes the full transport stack: ICE → DTLS → SRTP + SCTP. +// The SFU is DTLS client (active). tgcalls GroupNetworkManager hardcodes SSL_SERVER. +// +// iceControlling selects the ICE role: +// - true (Dial): SFU is controlling. Required for tgcalls GroupNetworkManager which +// hardcodes ICEROLE_CONTROLLED (non-standard). +// - false (Accept): SFU is controlled (standard for ICE-lite). Required for PeerConnection +// clients that follow RFC 8445 (full agent = controlling when remote is ice-lite). +func (p *Participant) Connect(ctx context.Context, remoteUfrag, remotePwd string, iceControlling bool) error { + // 1. ICE connection. + var iceConn *ice.Conn + var err error + if iceControlling { + iceConn, err = p.iceAgent.Dial(ctx, remoteUfrag, remotePwd) + } else { + iceConn, err = p.iceAgent.Accept(ctx, remoteUfrag, remotePwd) + } + if err != nil { + return fmt.Errorf("ICE dial: %w", err) + } + p.iceConn = iceConn + p.log.Infof("ICE connected") + + // 2. Demux: split DTLS and SRTP traffic. + p.demux = NewPacketDemux(iceConn, fmt.Sprintf("p%d", p.ID)) + + // 3. DTLS: client-side handshake over the DTLS endpoint. + // tgcalls GroupNetworkManager hardcodes SetDtlsRole(SSL_SERVER), so the SFU must be the DTLS client. + dtlsEndpoint := p.demux.DTLSEndpoint() + remoteAddr := dtlsEndpoint.RemoteAddr() + packetConn := WrapAsPacketConn(dtlsEndpoint) + + dtlsConn, err := dtls.Client(packetConn, remoteAddr, &dtls.Config{ + Certificates: []tls.Certificate{p.tlsCert}, + // Offer GCM profiles matching tgcalls GroupNetworkManager::getDefaulCryptoOptions() + // which enables enable_gcm_crypto_suites=true and disables AES-128-CM-SHA1-80. + SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{ + dtls.SRTP_AEAD_AES_256_GCM, + dtls.SRTP_AEAD_AES_128_GCM, + }, + ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, + InsecureSkipVerify: true, // tgcalls verifies fingerprint out-of-band; we skip TLS chain verification + LoggerFactory: p.loggerFactory, + }) + if err != nil { + p.demux.Close() + return fmt.Errorf("DTLS create: %w", err) + } + p.dtlsConn = dtlsConn + + // dtls.Client() is lazy; explicitly run the handshake before accessing ConnectionState. + if err := dtlsConn.HandshakeContext(ctx); err != nil { + p.demux.Close() + return fmt.Errorf("DTLS handshake: %w", err) + } + p.log.Infof("DTLS connected") + + // 4. Extract SRTP keying material from DTLS. + state, ok := dtlsConn.ConnectionState() + if !ok { + return fmt.Errorf("DTLS connection state not available") + } + + // Map the negotiated DTLS-SRTP protection profile to a pion/srtp ProtectionProfile. + negotiatedProfile, profileOk := dtlsConn.SelectedSRTPProtectionProfile() + if !profileOk { + p.demux.Close() + return fmt.Errorf("no SRTP protection profile negotiated") + } + var srtpProfile srtp.ProtectionProfile + switch negotiatedProfile { + case dtls.SRTP_AEAD_AES_256_GCM: + srtpProfile = srtp.ProtectionProfileAeadAes256Gcm + case dtls.SRTP_AEAD_AES_128_GCM: + srtpProfile = srtp.ProtectionProfileAeadAes128Gcm + case dtls.SRTP_AES128_CM_HMAC_SHA1_80: + srtpProfile = srtp.ProtectionProfileAes128CmHmacSha1_80 + case dtls.SRTP_AES128_CM_HMAC_SHA1_32: + srtpProfile = srtp.ProtectionProfileAes128CmHmacSha1_32 + default: + p.demux.Close() + return fmt.Errorf("unsupported SRTP protection profile: 0x%04x", negotiatedProfile) + } + p.log.Infof("Negotiated SRTP profile: 0x%04x", negotiatedProfile) + + srtpConfig := &srtp.Config{ + Profile: srtpProfile, + } + // SFU is DTLS client → isClient=true + if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, true); err != nil { + return fmt.Errorf("extract SRTP keys: %w", err) + } + + // Save keys and profile for creating SRTCP contexts. + p.srtpProfile = srtpProfile + p.srtpKeys = srtpConfig.Keys + + // 5. SRTP session over the SRTP endpoint (RTP only — RTCP is handled separately). + srtpEndpoint := p.demux.SRTPEndpoint() + srtpSession, err := srtp.NewSessionSRTP(srtpEndpoint, srtpConfig) + if err != nil { + return fmt.Errorf("create SRTP session: %w", err) + } + p.srtpSession = srtpSession + + srtpWriter, err := srtpSession.OpenWriteStream() + if err != nil { + return fmt.Errorf("open SRTP write stream: %w", err) + } + p.srtpWriter = srtpWriter + p.log.Infof("SRTP session established") + + // 5b. Create separate SRTCP contexts for manual RTCP handling. + // Remote context: decrypt SRTCP received from this participant (their local = our remote). + p.srtcpRemoteCtx, err = srtp.CreateContext( + p.srtpKeys.RemoteMasterKey, p.srtpKeys.RemoteMasterSalt, p.srtpProfile, + ) + if err != nil { + return fmt.Errorf("create SRTCP remote context: %w", err) + } + // Local context: encrypt SRTCP we send to this participant (our local keys). + p.srtcpLocalCtx, err = srtp.CreateContext( + p.srtpKeys.LocalMasterKey, p.srtpKeys.LocalMasterSalt, p.srtpProfile, + ) + if err != nil { + return fmt.Errorf("create SRTCP local context: %w", err) + } + p.log.Infof("SRTCP contexts created") + + // 5c. Start RTCP read loop. + go p.readRTCPLoop() + + // 6. SCTP association over DTLS. + sctpAssoc, err := sctp.Server(sctp.Config{ + NetConn: dtlsConn, + LoggerFactory: p.loggerFactory, + }) + if err != nil { + return fmt.Errorf("create SCTP association: %w", err) + } + p.sctpAssoc = sctpAssoc + p.log.Infof("SCTP association established") + + // 7. Start goroutine to accept data channels. + go p.acceptDataChannel() + + return nil +} + +// acceptDataChannel waits for the client to open a data channel and reads Colibri messages. +func (p *Participant) acceptDataChannel() { + dc, err := datachannel.Accept(p.sctpAssoc, &datachannel.Config{ + LoggerFactory: p.loggerFactory, + }) + if err != nil { + select { + case <-p.closed: + return // Expected during shutdown. + default: + p.log.Warnf("Accept data channel: %v", err) + return + } + } + p.dataChannel = dc + p.log.Infof("Data channel accepted") + + buf := make([]byte, 4096) + for { + n, isString, err := dc.ReadDataChannel(buf) + if err != nil { + select { + case <-p.closed: + return + default: + p.log.Debugf("Data channel read error: %v", err) + return + } + } + if isString { + msg := string(buf[:n]) + p.log.Debugf("Colibri message: %s", msg) + if p.onColibriMessage != nil { + p.onColibriMessage(p.ID, msg) + } + } else { + p.log.Debugf("Data channel binary message (%d bytes)", n) + } + } +} + +// SetColibriCallback sets the callback for incoming Colibri data channel messages. +func (p *Participant) SetColibriCallback(cb func(participantID int, msg string)) { + p.onColibriMessage = cb +} + +// SetRTCPFeedbackCallback sets the callback for PLI/FIR RTCP feedback from this participant. +func (p *Participant) SetRTCPFeedbackCallback(cb func(participantID int, mediaSSRC uint32, isFIR bool)) { + p.onRTCPFeedback = cb +} + +// readRTCPLoop reads encrypted SRTCP packets from the demux RTCP channel, +// decrypts them, parses for PLI/FIR, and invokes the feedback callback. +func (p *Participant) readRTCPLoop() { + rtcpCh := p.demux.RTCPChannel() + decryptBuf := make([]byte, 8192) + pktCount := 0 + + for { + select { + case <-p.closed: + return + case encrypted, ok := <-rtcpCh: + if !ok { + return + } + + // Decrypt SRTCP. + decrypted, err := p.srtcpRemoteCtx.DecryptRTCP(decryptBuf[:0], encrypted, nil) + if err != nil { + pktCount++ + if pktCount <= 5 { + p.log.Debugf("SRTCP decrypt error: %v", err) + } + continue + } + + // Parse RTCP compound packet. + packets, err := rtcp.Unmarshal(decrypted) + if err != nil { + p.log.Debugf("RTCP unmarshal error: %v", err) + continue + } + + for _, pkt := range packets { + switch fb := pkt.(type) { + case *rtcp.PictureLossIndication: + p.log.Infof("Received PLI from participant %d for MediaSSRC=%d", p.ID, fb.MediaSSRC) + if p.onRTCPFeedback != nil { + p.onRTCPFeedback(p.ID, fb.MediaSSRC, false) + } + case *rtcp.FullIntraRequest: + for _, entry := range fb.FIR { + p.log.Infof("Received FIR from participant %d for SSRC=%d", p.ID, entry.SSRC) + if p.onRTCPFeedback != nil { + p.onRTCPFeedback(p.ID, entry.SSRC, true) + } + } + case *rtcp.ReceiverEstimatedMaximumBitrate: + bps := float64(fb.Bitrate) + p.bwEstimator.OnREMB(bps) + p.log.Debugf("REMB from participant %d: %.0f bps (smoothed=%.0f, effective=%.0f)", + p.ID, bps, p.bwEstimator.SmoothedBps(), p.bwEstimator.EffectiveBps()) + } + } + } + } +} + +// WriteRTCP sends a plaintext RTCP packet to this participant, encrypting it with +// the local SRTCP context and writing directly to the ICE connection. +func (p *Participant) WriteRTCP(data []byte) error { + if p.srtcpLocalCtx == nil || p.iceConn == nil { + return fmt.Errorf("SRTCP context or ICE conn not established") + } + if p.egressSim.IsPassthrough() { + return p.writeRTCPDirect(data) + } + p.egressSim.Send(data, func(delayed []byte) { + p.writeRTCPDirect(delayed) + }) + return nil +} + +func (p *Participant) writeRTCPDirect(data []byte) error { + p.srtcpMu.Lock() + encrypted, err := p.srtcpLocalCtx.EncryptRTCP(nil, data, nil) + p.srtcpMu.Unlock() + if err != nil { + return fmt.Errorf("encrypt SRTCP: %w", err) + } + _, err = p.iceConn.Write(encrypted) + return err +} + +// SetRequestedLayer sets the video layer this receiver wants from a given sender. +func (p *Participant) SetRequestedLayer(senderID int, layer int) { + p.videoLayerMu.Lock() + p.requestedLayers[senderID] = layer + p.videoLayerMu.Unlock() +} + +// GetRequestedLayer returns the video layer this receiver wants from a given sender. +// Returns -1 if no layer is requested (meaning: don't forward video from this sender). +func (p *Participant) GetRequestedLayer(senderID int) int { + p.videoLayerMu.RLock() + defer p.videoLayerMu.RUnlock() + if layer, ok := p.requestedLayers[senderID]; ok { + return layer + } + return -1 +} + +// SetSelectedLayer sets the video layer the SFU actually forwards from a given sender to this receiver. +func (p *Participant) SetSelectedLayer(senderID int, layer int) { + p.selectedLayerMu.Lock() + p.selectedLayers[senderID] = layer + p.selectedLayerMu.Unlock() +} + +// GetSelectedLayer returns the video layer the SFU forwards from a given sender to this receiver. +// Returns -1 if no layer is selected (don't forward). +func (p *Participant) GetSelectedLayer(senderID int) int { + p.selectedLayerMu.RLock() + defer p.selectedLayerMu.RUnlock() + if layer, ok := p.selectedLayers[senderID]; ok { + return layer + } + return -1 +} + +// SendText sends a UTF-8 string message over the data channel. +// Returns an error if the data channel is not yet established. +func (p *Participant) SendText(msg string) error { + dc := p.dataChannel + if dc == nil { + return fmt.Errorf("data channel not established") + } + _, err := dc.WriteDataChannel([]byte(msg), true) + return err +} + +// WriteRTP sends an encrypted RTP packet to this participant via the SRTP write stream. +func (p *Participant) WriteRTP(pkt []byte) (int, error) { + if p.srtpWriter == nil { + return 0, fmt.Errorf("SRTP session not established") + } + if p.egressSim.IsPassthrough() { + return p.srtpWriter.Write(pkt) + } + var n int + var writeErr error + p.egressSim.Send(pkt, func(delayed []byte) { + n, writeErr = p.srtpWriter.Write(delayed) + }) + return n, writeErr +} + +// AcceptStream blocks until a new SRTP read stream appears (new SSRC from client). +// Returns the read stream and its SSRC. +func (p *Participant) AcceptStream() (*srtp.ReadStreamSRTP, uint32, error) { + if p.srtpSession == nil { + return nil, 0, fmt.Errorf("SRTP session not established") + } + return p.srtpSession.AcceptStream() +} + +// Close tears down all transport layers in order. +func (p *Participant) Close() error { + var firstErr error + p.once.Do(func() { + close(p.closed) + + if p.ingressSim != nil { + p.ingressSim.Close() + } + if p.egressSim != nil { + p.egressSim.Close() + } + + if p.dataChannel != nil { + if err := p.dataChannel.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.sctpAssoc != nil { + if err := p.sctpAssoc.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.srtpSession != nil { + if err := p.srtpSession.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.dtlsConn != nil { + if err := p.dtlsConn.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.demux != nil { + if err := p.demux.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.iceConn != nil { + if err := p.iceConn.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if p.iceAgent != nil { + if err := p.iceAgent.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + + p.log.Infof("Participant %d closed", p.ID) + }) + return firstErr +} + +// formatFingerprint converts a hash byte slice to colon-separated uppercase hex. +func formatFingerprint(hash []byte) string { + result := make([]byte, 0, len(hash)*3-1) + for i, b := range hash { + if i > 0 { + result = append(result, ':') + } + result = append(result, fmt.Sprintf("%02X", b)...) + } + return string(result) +} diff --git a/tools/go_sfu/sfu.go b/tools/go_sfu/sfu.go new file mode 100644 index 0000000000..dd5ec68857 --- /dev/null +++ b/tools/go_sfu/sfu.go @@ -0,0 +1,1240 @@ +package main + +/* +#include +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/pion/ice/v4" + "github.com/pion/logging" + "github.com/pion/rtcp" +) + +// --- JSON types for tgcalls join protocol --- + +type joinPayload struct { + SSRC int32 `json:"ssrc"` // signed in JSON, cast to uint32 + Ufrag string `json:"ufrag"` + Pwd string `json:"pwd"` + Fingerprints []fingerprintJSON `json:"fingerprints"` + SSRCGroups []ssrcGroupJSON `json:"ssrc-groups"` +} + +type ssrcGroupJSON struct { + Semantics string `json:"semantics"` // "SIM" or "FID" + Sources []int32 `json:"sources"` // tgcalls serializes as "sources", not "ssrcs" +} + +// ssrcInfo identifies the owner and type of an SSRC in the registry. +type ssrcInfo struct { + participantID int + kind string // "audio", "video", "video-rtx" + layer int // -1 for audio, 0/1/2 for video simulcast layers +} + +// SimulcastLayer holds the primary and RTX SSRCs for one simulcast layer. +type SimulcastLayer struct { + SSRC uint32 + FidSSRC uint32 +} + +type fingerprintJSON struct { + Hash string `json:"hash"` + Fingerprint string `json:"fingerprint"` + Setup string `json:"setup"` +} + +type joinResponse struct { + Transport transportJSON `json:"transport"` + Video *videoResponseJSON `json:"video,omitempty"` +} + +type videoResponseJSON struct { + Endpoint string `json:"endpoint"` + ServerSSRCs []videoServerSSRC `json:"server_ssrcs,omitempty"` + PayloadTypes []videoPayloadTypeJSON `json:"payload-types"` + RTPHdrexts []rtpHdrextJSON `json:"rtp-hdrexts"` +} + +type videoServerSSRC struct { + SSRC int32 `json:"ssrc"` + Groups []ssrcGroupJSON `json:"ssrc-groups,omitempty"` +} + +type videoPayloadTypeJSON struct { + ID int `json:"id"` + Name string `json:"name"` + Clockrate int `json:"clockrate"` + Channels int `json:"channels,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` + Feedback []rtcpFeedbackJSON `json:"rtcp-fbs,omitempty"` +} + +type rtcpFeedbackJSON struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` +} + +type rtpHdrextJSON struct { + ID int `json:"id"` + URI string `json:"uri"` +} + +type transportJSON struct { + Ufrag string `json:"ufrag"` + Pwd string `json:"pwd"` + Fingerprints []fingerprintJSON `json:"fingerprints"` + Candidates []candidateJSON `json:"candidates"` +} + +type candidateJSON struct { + Port string `json:"port"` + Protocol string `json:"protocol"` + Network string `json:"network"` + Generation string `json:"generation"` + ID string `json:"id"` + Component string `json:"component"` + Foundation string `json:"foundation"` + Priority string `json:"priority"` + IP string `json:"ip"` + Type string `json:"type"` +} + +// --- SFU --- + +type SFU struct { + mu sync.RWMutex + participants map[int]*Participant + ssrcRegistry map[uint32]ssrcInfo + videoSSRCs map[int][]SimulcastLayer // participantID -> simulcast layers + rtxBuffers map[int]*RtxRingBuffer // senderID -> RTX ring buffer + layerSelectors map[[2]int]*LayerSelector // [receiverID, senderID] -> selector + maxActiveLayer map[int]int // senderID -> highest layer with traffic + twccGenerators map[int]*TransportCCGenerator // senderID -> transport-cc generator + loggerFactory logging.LoggerFactory + log logging.LeveledLogger + ctx context.Context + cancel context.CancelFunc +} + +func NewSFU() *SFU { + lf := logging.NewDefaultLoggerFactory() + lf.DefaultLogLevel = logging.LogLevelDebug + ctx, cancel := context.WithCancel(context.Background()) + return &SFU{ + participants: make(map[int]*Participant), + ssrcRegistry: make(map[uint32]ssrcInfo), + videoSSRCs: make(map[int][]SimulcastLayer), + rtxBuffers: make(map[int]*RtxRingBuffer), + layerSelectors: make(map[[2]int]*LayerSelector), + maxActiveLayer: make(map[int]int), + twccGenerators: make(map[int]*TransportCCGenerator), + loggerFactory: lf, + log: lf.NewLogger("sfu"), + ctx: ctx, + cancel: cancel, + } +} + +// Join processes a participant's join payload and returns the SFU's join response JSON. +// iceControlling: true for CustomImpl clients (which hardcode CONTROLLED role), +// false for PeerConnection clients (standard ICE: full agent is controlling when remote is ice-lite). +func (s *SFU) Join(participantID int, joinPayloadJSON string, iceControlling bool) (string, error) { + // 1. Parse join payload. + var payload joinPayload + if err := json.Unmarshal([]byte(joinPayloadJSON), &payload); err != nil { + return "", fmt.Errorf("parse join payload: %w", err) + } + + // 2. Extract audio SSRC (signed int32 -> uint32). + audioSSRC := uint32(payload.SSRC) + + // 3. Extract fingerprint from payload (use first sha-256 fingerprint). + var remoteFingerprint string + for _, fp := range payload.Fingerprints { + if fp.Hash == "sha-256" { + remoteFingerprint = fp.Fingerprint + break + } + } + if remoteFingerprint == "" && len(payload.Fingerprints) > 0 { + remoteFingerprint = payload.Fingerprints[0].Fingerprint + } + + // 4. Parse video ssrc-groups into SimulcastLayers. + var simSSRCs []uint32 // SIM group: primary SSRCs per layer + fidMap := make(map[uint32]uint32) // primary SSRC -> RTX SSRC + for _, g := range payload.SSRCGroups { + switch g.Semantics { + case "SIM": + for _, v := range g.Sources { + simSSRCs = append(simSSRCs, uint32(v)) + } + case "FID": + if len(g.Sources) == 2 { + fidMap[uint32(g.Sources[0])] = uint32(g.Sources[1]) + } + } + } + + var videoLayers []SimulcastLayer + for _, primary := range simSSRCs { + layer := SimulcastLayer{SSRC: primary} + if rtx, ok := fidMap[primary]; ok { + layer.FidSSRC = rtx + } + videoLayers = append(videoLayers, layer) + } + + // 5. Create participant config. + config := ParticipantConfig{ + AudioSSRC: audioSSRC, + Ufrag: payload.Ufrag, + Pwd: payload.Pwd, + Fingerprint: remoteFingerprint, + } + + // 6. Create participant. + p, err := NewParticipant(participantID, config, s.loggerFactory) + if err != nil { + return "", fmt.Errorf("create participant: %w", err) + } + + // 7. Wire Colibri callback for video constraint messages. + p.SetColibriCallback(s.handleColibriMessage) + + // 7b. Wire RTCP feedback callback for PLI/FIR forwarding. + p.SetRTCPFeedbackCallback(s.handleRTCPFeedback) + + // 8. Gather ICE candidates. + candidates, err := p.GatherCandidates() + if err != nil { + p.Close() + return "", fmt.Errorf("gather candidates: %w", err) + } + + // 9. Register participant and all SSRCs. + s.mu.Lock() + s.participants[participantID] = p + s.ssrcRegistry[audioSSRC] = ssrcInfo{participantID: participantID, kind: "audio", layer: -1} + if len(videoLayers) > 0 { + s.videoSSRCs[participantID] = videoLayers + for i, vl := range videoLayers { + s.ssrcRegistry[vl.SSRC] = ssrcInfo{participantID: participantID, kind: "video", layer: i} + if vl.FidSSRC != 0 { + s.ssrcRegistry[vl.FidSSRC] = ssrcInfo{participantID: participantID, kind: "video-rtx", layer: i} + } + } + s.rtxBuffers[participantID] = NewRtxRingBuffer(200) + } + s.mu.Unlock() + + s.log.Infof("Registered participant %d: audio=%d, video layers=%d", participantID, audioSSRC, len(videoLayers)) + for i, vl := range videoLayers { + s.log.Infof(" video layer %d: ssrc=%d fid=%d", i, vl.SSRC, vl.FidSSRC) + } + + // 10. Build response JSON. + var candidatesJSON []candidateJSON + for _, c := range candidates { + candidatesJSON = append(candidatesJSON, iceCandidateToJSON(c)) + } + + resp := joinResponse{ + Transport: transportJSON{ + Ufrag: p.LocalUfrag(), + Pwd: p.LocalPwd(), + Fingerprints: []fingerprintJSON{ + { + Hash: "sha-256", + Fingerprint: p.Fingerprint(), + Setup: "active", // SFU is DTLS client (active); tgcalls is SSL_SERVER (passive) + }, + }, + Candidates: candidatesJSON, + }, + } + + // Add video section if participant has video SSRCs. + if len(videoLayers) > 0 { + resp.Video = &videoResponseJSON{ + Endpoint: fmt.Sprintf("%d", participantID), + PayloadTypes: []videoPayloadTypeJSON{ + { + ID: 100, + Name: "H264", + Clockrate: 90000, + Parameters: map[string]string{ + "profile-level-id": "42e01f", + "packetization-mode": "1", + }, + Feedback: []rtcpFeedbackJSON{ + {Type: "goog-remb"}, + {Type: "transport-cc"}, + {Type: "ccm", Subtype: "fir"}, + {Type: "nack"}, + {Type: "nack", Subtype: "pli"}, + }, + }, + { + ID: 101, + Name: "rtx", + Clockrate: 90000, + Parameters: map[string]string{ + "apt": "100", + }, + }, + }, + RTPHdrexts: []rtpHdrextJSON{ + {ID: 2, URI: "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"}, + {ID: 3, URI: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"}, + {ID: 13, URI: "urn:3gpp:video-orientation"}, + }, + } + } + + respBytes, err := json.Marshal(resp) + if err != nil { + return "", fmt.Errorf("marshal response: %w", err) + } + + // 11. Start connection + RTP forwarding in background. + go func() { + if err := p.Connect(s.ctx, payload.Ufrag, payload.Pwd, iceControlling); err != nil { + s.log.Warnf("Participant %d connect failed: %v", participantID, err) + return + } + s.log.Infof("Participant %d connected, starting RTP forwarding", participantID) + + // Start transport-cc feedback generator for this participant. + twccGen := NewTransportCCGenerator(func(data []byte) { + if err := p.WriteRTCP(data); err != nil { + s.log.Debugf("TWCC feedback to participant %d failed: %v", participantID, err) + } + }) + s.mu.Lock() + s.twccGenerators[participantID] = twccGen + s.mu.Unlock() + + // Broadcast updated SSRC list to all participants (after data channel is ready). + // Small delay to let the data channel establish. + time.Sleep(500 * time.Millisecond) + s.broadcastActiveSSRCs() + s.broadcastActiveVideoSSRCs() + + s.forwardRTP(p) + }() + + return string(respBytes), nil +} + +// readParticipants returns a snapshot of the current participant map. +func (s *SFU) readParticipants() map[int]*Participant { + s.mu.RLock() + defer s.mu.RUnlock() + snap := make(map[int]*Participant, len(s.participants)) + for id, p := range s.participants { + snap[id] = p + } + return snap +} + +// forwardRTP reads RTP from a participant and forwards to all others. +// For video/video-rtx SSRCs, only forwards to receivers whose requested layer matches. +// Audio is forwarded unconditionally to all other participants. +func (s *SFU) forwardRTP(from *Participant) { + for { + stream, ssrc, err := from.AcceptStream() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + s.log.Warnf("Participant %d AcceptStream error: %v", from.ID, err) + return + } + } + + // Register SSRC if not already known (assume audio for undeclared SSRCs). + s.mu.Lock() + if _, exists := s.ssrcRegistry[ssrc]; !exists { + s.log.Warnf("Participant %d: undeclared SSRC %d, registering as audio", from.ID, ssrc) + s.ssrcRegistry[ssrc] = ssrcInfo{participantID: from.ID, kind: "audio", layer: -1} + } + info := s.ssrcRegistry[ssrc] + s.mu.Unlock() + + s.log.Infof("Participant %d: accepted stream SSRC=%d (kind=%s, layer=%d)", from.ID, ssrc, info.kind, info.layer) + + go func(streamInfo ssrcInfo) { + buf := make([]byte, 1500) + for { + n, err := stream.Read(buf) + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + s.log.Debugf("Participant %d stream read error: %v", from.ID, err) + return + } + } + + pkt := make([]byte, n) + copy(pkt, buf[:n]) + + from.ingressSim.Send(pkt, func(simPkt []byte) { + // Record transport-cc arrival after ingress simulation. + twccSeq, ok := parseTWCCSeq(simPkt, 3) + if ok { + s.mu.RLock() + gen := s.twccGenerators[from.ID] + s.mu.RUnlock() + if gen != nil { + gen.RecordArrival(twccSeq) + } + } + + s.processIncomingRTP(from, simPkt, ssrc, streamInfo) + }) + } + }(info) + } +} + +// processIncomingRTP handles a single RTP packet from a participant after ingress simulation. +func (s *SFU) processIncomingRTP(from *Participant, pkt []byte, ssrc uint32, streamInfo ssrcInfo) { + if streamInfo.kind == "audio" { + // Audio: forward to all other participants unconditionally. + s.mu.RLock() + for id, p := range s.participants { + if id == from.ID { + continue + } + if _, err := p.WriteRTP(pkt); err != nil { + s.log.Debugf("WriteRTP to participant %d failed: %v", id, err) + } + } + s.mu.RUnlock() + } else { + // Video/video-rtx: forward the best available layer to each receiver. + // Track max active layer for video (not video-rtx) packets. + if streamInfo.kind == "video" { + s.mu.Lock() + rtxBuf := s.rtxBuffers[from.ID] + maxActiveIncreased := false + if cur, ok := s.maxActiveLayer[from.ID]; !ok || streamInfo.layer > cur { + s.maxActiveLayer[from.ID] = streamInfo.layer + maxActiveIncreased = true + } + newMax := s.maxActiveLayer[from.ID] + var selectorsToNotify []*LayerSelector + if maxActiveIncreased { + for key, sel := range s.layerSelectors { + if key[1] == from.ID { + selectorsToNotify = append(selectorsToNotify, sel) + } + } + } + s.mu.Unlock() + + // Notify layer selectors outside the lock to avoid deadlock. + for _, sel := range selectorsToNotify { + sel.OnMaxActiveLayerIncreased(newMax) + } + + if rtxBuf != nil && len(pkt) >= 4 { + seqNum := uint16(pkt[2])<<8 | uint16(pkt[3]) + var ts uint32 + if len(pkt) >= 8 { + ts = uint32(pkt[4])<<24 | uint32(pkt[5])<<16 | uint32(pkt[6])<<8 | uint32(pkt[7]) + } + rtxBuf.Push(pkt, seqNum, ts) + } + } + + s.mu.RLock() + maxActive, hasActive := s.maxActiveLayer[from.ID] + s.mu.RUnlock() + + snap := s.readParticipants() + for id, p := range snap { + if id == from.ID { + continue + } + // Determine effective layer: use selectedLayer if set, + // otherwise use maxActiveLayer (best available). + selectedLayer := p.GetSelectedLayer(from.ID) + requestedLayer := p.GetRequestedLayer(from.ID) + var effectiveLayer int + if selectedLayer >= 0 { + effectiveLayer = selectedLayer + } else if requestedLayer >= 0 { + // Pre-selector: forward at best available, capped by request. + effectiveLayer = requestedLayer + } else { + continue // receiver doesn't want video from this sender + } + // Clamp to what the sender actually produces. + if hasActive && effectiveLayer > maxActive { + effectiveLayer = maxActive + } + if streamInfo.layer == effectiveLayer { + fwdPkt := pkt + // If forwarding a non-base layer, rewrite the SSRC in the + // RTP header to the primary (layer 0) SSRC. The receiver's + // video sink is attached to the primary SSRC only. + if effectiveLayer > 0 && len(fwdPkt) >= 12 { + s.mu.RLock() + senderLayers := s.videoSSRCs[from.ID] + s.mu.RUnlock() + if len(senderLayers) > 0 { + primarySSRC := senderLayers[0].SSRC + var rtxSSRC uint32 + if streamInfo.kind == "video-rtx" && senderLayers[0].FidSSRC != 0 { + rtxSSRC = senderLayers[0].FidSSRC + } + fwdPkt = make([]byte, len(pkt)) + copy(fwdPkt, pkt) + targetSSRC := primarySSRC + if streamInfo.kind == "video-rtx" && rtxSSRC != 0 { + targetSSRC = rtxSSRC + } + fwdPkt[8] = byte(targetSSRC >> 24) + fwdPkt[9] = byte(targetSSRC >> 16) + fwdPkt[10] = byte(targetSSRC >> 8) + fwdPkt[11] = byte(targetSSRC) + } + } + if _, err := p.WriteRTP(fwdPkt); err != nil { + s.log.Debugf("WriteRTP video to participant %d failed: %v", id, err) + } + } + } + } +} + +// heightToLayer maps a requested video height to a simulcast layer index. +func heightToLayer(height int) int { + if height <= 0 { + return -1 + } + if height <= 90 { + return 0 + } + if height <= 180 { + return 1 + } + return 2 +} + +// handleColibriMessage processes an incoming Colibri message from a participant. +func (s *SFU) handleColibriMessage(participantID int, msg string) { + var base struct { + ColibriClass string `json:"colibriClass"` + } + if err := json.Unmarshal([]byte(msg), &base); err != nil { + s.log.Debugf("Participant %d: invalid Colibri JSON: %v", participantID, err) + return + } + + switch base.ColibriClass { + case "ReceiverVideoConstraints": + s.handleReceiverVideoConstraints(participantID, msg) + default: + s.log.Debugf("Participant %d: unhandled Colibri class: %s", participantID, base.ColibriClass) + } +} + +// handleRTCPFeedback is called when a participant sends PLI or FIR for a MediaSSRC. +// It looks up the sender of that SSRC and forwards a new PLI to them. +func (s *SFU) handleRTCPFeedback(fromID int, mediaSSRC uint32, isFIR bool) { + s.mu.RLock() + info, ok := s.ssrcRegistry[mediaSSRC] + if !ok { + s.mu.RUnlock() + s.log.Debugf("RTCP feedback from %d: unknown MediaSSRC=%d", fromID, mediaSSRC) + return + } + sender, senderOk := s.participants[info.participantID] + s.mu.RUnlock() + + if !senderOk { + s.log.Debugf("RTCP feedback from %d: sender %d not found for MediaSSRC=%d", fromID, info.participantID, mediaSSRC) + return + } + + kind := "PLI" + if isFIR { + kind = "FIR" + } + s.log.Infof("Forwarding %s to participant %d for MediaSSRC=%d (requested by %d)", kind, info.participantID, mediaSSRC, fromID) + + // Construct and send PLI to the sender (PLI is simpler and universally supported). + pli := &rtcp.PictureLossIndication{ + SenderSSRC: 0, + MediaSSRC: mediaSSRC, + } + data, err := rtcp.Marshal([]rtcp.Packet{pli}) + if err != nil { + s.log.Warnf("Failed to marshal PLI: %v", err) + return + } + + if err := sender.WriteRTCP(data); err != nil { + s.log.Debugf("Failed to send PLI to participant %d: %v", info.participantID, err) + } +} + +type receiverVideoConstraints struct { + DefaultConstraints *videoConstraint `json:"defaultConstraints"` + Constraints map[string]videoConstraint `json:"constraints"` +} + +type videoConstraint struct { + MinHeight int `json:"minHeight"` + MaxHeight int `json:"maxHeight"` +} + +type senderVideoConstraints struct { + ColibriClass string `json:"colibriClass"` + VideoConstraints senderVideoConstraint `json:"videoConstraints"` +} + +type senderVideoConstraint struct { + IdealHeight int `json:"idealHeight"` +} + +func (s *SFU) handleReceiverVideoConstraints(receiverID int, msg string) { + var rvc receiverVideoConstraints + if err := json.Unmarshal([]byte(msg), &rvc); err != nil { + s.log.Warnf("Participant %d: bad ReceiverVideoConstraints: %v", receiverID, err) + return + } + + s.mu.RLock() + receiver, ok := s.participants[receiverID] + s.mu.RUnlock() + if !ok { + return + } + + // Track which senders are affected so we can update their SenderVideoConstraints. + affectedSenders := make(map[int]bool) + + // Apply per-endpoint constraints and wire LayerSelector. + for endpointStr, constraint := range rvc.Constraints { + var senderID int + if _, err := fmt.Sscanf(endpointStr, "%d", &senderID); err != nil { + continue + } + layer := heightToLayer(constraint.MaxHeight) + receiver.SetRequestedLayer(senderID, layer) + s.ensureLayerSelector(receiverID, senderID, layer) + affectedSenders[senderID] = true + } + + // Apply default constraints to all other senders with video. + // Collect senderIDs under RLock, release, then call ensureLayerSelector (needs write lock). + if rvc.DefaultConstraints != nil { + defaultLayer := heightToLayer(rvc.DefaultConstraints.MaxHeight) + var defaultSenders []int + s.mu.RLock() + for senderID := range s.videoSSRCs { + if senderID == receiverID { + continue + } + if !affectedSenders[senderID] { + defaultSenders = append(defaultSenders, senderID) + } + } + s.mu.RUnlock() + + for _, senderID := range defaultSenders { + receiver.SetRequestedLayer(senderID, defaultLayer) + s.ensureLayerSelector(receiverID, senderID, defaultLayer) + affectedSenders[senderID] = true + } + } + + // Send SenderVideoConstraints to each affected sender. + // idealHeight = max height any receiver wants from this sender. + for senderID := range affectedSenders { + s.sendSenderVideoConstraints(senderID) + } + + // Send PLI to each sender that the receiver wants video from. + // This triggers a keyframe so the decoder can start producing frames. + for senderID := range affectedSenders { + layer := receiver.GetRequestedLayer(senderID) + if layer >= 0 { + s.mu.RLock() + layers := s.videoSSRCs[senderID] + s.mu.RUnlock() + if len(layers) > 0 { + s.handleRTCPFeedback(receiverID, layers[0].SSRC, false) + } + } + } +} + +// ensureLayerSelector creates or updates a LayerSelector for a (receiver, sender) pair. +func (s *SFU) ensureLayerSelector(receiverID, senderID, maxLayer int) { + if maxLayer < 0 { + return // no video requested from this sender + } + + key := [2]int{receiverID, senderID} + + s.mu.Lock() + existing, exists := s.layerSelectors[key] + if exists { + s.mu.Unlock() + existing.SetMaxLayer(maxLayer) + return + } + + receiver, recvOk := s.participants[receiverID] + videoLayers := s.videoSSRCs[senderID] + s.mu.Unlock() + + if !recvOk { + return + } + + initialLayer := maxLayer + if initialLayer > 2 { + initialLayer = 2 + } + + // Set selectedLayer immediately, clamped to what the sender produces. + // The forwardRTP loop will further clamp to maxActiveLayer on each packet. + s.mu.RLock() + maxActive, hasActive := s.maxActiveLayer[senderID] + s.mu.RUnlock() + layer := initialLayer + if hasActive && layer > maxActive { + layer = maxActive + } + receiver.SetSelectedLayer(senderID, layer) + + layersCopy := make([]SimulcastLayer, len(videoLayers)) + copy(layersCopy, videoLayers) + + cb := LayerSelectorCallbacks{ + GetEffectiveBW: func() float64 { + return receiver.bwEstimator.EffectiveBps() + }, + SetSelectedLayer: func(layer int) { + receiver.SetSelectedLayer(senderID, layer) + }, + SendPLI: func(ssrc uint32) { + s.handleRTCPFeedback(receiverID, ssrc, false) + }, + GetSenderVideoLayers: func() []SimulcastLayer { + return layersCopy + }, + GetRtxBuffer: func() *RtxRingBuffer { + s.mu.RLock() + defer s.mu.RUnlock() + return s.rtxBuffers[senderID] + }, + SendRtxPadding: func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) { + hdr := make([]byte, 12+len(rtxPayload)) + hdr[0] = 0x80 // V=2 + hdr[1] = 101 // PT=101 (RTX for H264) + hdr[2] = byte(seqNum >> 8) + hdr[3] = byte(seqNum) + hdr[4] = byte(timestamp >> 24) + hdr[5] = byte(timestamp >> 16) + hdr[6] = byte(timestamp >> 8) + hdr[7] = byte(timestamp) + hdr[8] = byte(rtxSSRC >> 24) + hdr[9] = byte(rtxSSRC >> 16) + hdr[10] = byte(rtxSSRC >> 8) + hdr[11] = byte(rtxSSRC) + copy(hdr[12:], rtxPayload) + if _, err := receiver.WriteRTP(hdr); err != nil { + s.log.Debugf("RTX padding to participant %d failed: %v", receiverID, err) + } + }, + Log: func(level string, format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + if level == "INFO" { + s.log.Infof("%s", msg) + } else { + s.log.Debugf("%s", msg) + } + }, + } + + ls := NewLayerSelector(receiverID, senderID, initialLayer, maxLayer, cb) + + s.mu.Lock() + s.layerSelectors[key] = ls + s.mu.Unlock() +} + +func (s *SFU) sendSenderVideoConstraints(senderID int) { + snap := s.readParticipants() + + maxHeight := 0 + for id, p := range snap { + if id == senderID { + continue + } + layer := p.GetRequestedLayer(senderID) + var h int + switch layer { + case 0: + h = 90 + case 1: + h = 180 + case 2: + h = 720 + default: + continue + } + if h > maxHeight { + maxHeight = h + } + } + + sender, ok := snap[senderID] + if !ok { + return + } + + svc := senderVideoConstraints{ + ColibriClass: "SenderVideoConstraints", + VideoConstraints: senderVideoConstraint{IdealHeight: maxHeight}, + } + data, _ := json.Marshal(svc) + if err := sender.SendText(string(data)); err != nil { + s.log.Debugf("SendText SenderVideoConstraints to %d: %v", senderID, err) + } +} + +// QuerySSRC returns the participant ID for a given SSRC, or -1 if unknown. +func (s *SFU) QuerySSRC(ssrc uint32) int { + s.mu.RLock() + defer s.mu.RUnlock() + if info, ok := s.ssrcRegistry[ssrc]; ok { + return info.participantID + } + return -1 +} + +// QueryVideoSSRCs returns a JSON array of simulcast layers for a given participant. +// Format: [{"ssrc":N,"fidSsrc":M},...] +// Returns "[]" if the participant has no video SSRCs. +func (s *SFU) QueryVideoSSRCs(participantID int) string { + s.mu.RLock() + defer s.mu.RUnlock() + + layers, ok := s.videoSSRCs[participantID] + if !ok || len(layers) == 0 { + return "[]" + } + + type layerJSON struct { + SSRC uint32 `json:"ssrc"` + FidSSRC uint32 `json:"fidSsrc"` + } + out := make([]layerJSON, len(layers)) + for i, l := range layers { + out[i] = layerJSON{SSRC: l.SSRC, FidSSRC: l.FidSSRC} + } + data, _ := json.Marshal(out) + return string(data) +} + +// SetNetworkParams configures network simulation for a participant. +// direction: 0 = ingress (from client), 1 = egress (to client). +func (s *SFU) SetNetworkParams(participantID int, direction int, delayMs, jitterMs int, dropRate float64, bandwidthBps int64) { + s.mu.RLock() + p, ok := s.participants[participantID] + s.mu.RUnlock() + if !ok { + return + } + var sim *NetworkSimulator + if direction == 0 { + sim = p.ingressSim + } else { + sim = p.egressSim + } + if sim != nil { + sim.SetParams(delayMs, jitterMs, dropRate, bandwidthBps) + } + dirName := "ingress" + if direction == 1 { + dirName = "egress" + } + s.log.Infof("Participant %d %s: delay=%dms jitter=%dms drop=%.2f bw=%d bps", + participantID, dirName, delayMs, jitterMs, dropRate, bandwidthBps) +} + +// broadcastActiveSSRCs sends the current set of active audio SSRCs to all connected participants. +// Each participant receives a list excluding their own SSRC. +func (s *SFU) broadcastActiveSSRCs() { + s.mu.RLock() + defer s.mu.RUnlock() + + // Collect all audio SSRCs per participant. + participantSSRCs := make(map[int]uint32) // participantID -> audioSSRC + for ssrc, info := range s.ssrcRegistry { + if info.kind == "audio" { + if _, exists := participantSSRCs[info.participantID]; !exists { + participantSSRCs[info.participantID] = ssrc + } + } + } + + for id, p := range s.participants { + var ssrcs []int32 + for otherID, ssrc := range participantSSRCs { + if otherID == id { + continue + } + ssrcs = append(ssrcs, int32(ssrc)) + } + + msg := buildActiveSSRCsMessage(ssrcs) + if err := p.SendText(msg); err != nil { + s.log.Debugf("SendText to participant %d: %v", id, err) + } + } +} + +// broadcastActiveVideoSSRCs sends the current set of active video SSRCs to all connected participants. +// Each participant receives a list excluding their own video SSRCs. +func (s *SFU) broadcastActiveVideoSSRCs() { + s.mu.RLock() + defer s.mu.RUnlock() + + // Only broadcast if any participant has video. + if len(s.videoSSRCs) == 0 { + return + } + + for id, p := range s.participants { + var entries []videoSSRCEntry + for otherID, layers := range s.videoSSRCs { + if otherID == id { + continue + } + if len(layers) == 0 { + continue + } + entry := videoSSRCEntry{ + EndpointID: fmt.Sprintf("%d", otherID), + SSRC: int32(layers[0].SSRC), + } + // Build SIM group. + simGroup := ssrcGroupJSON{Semantics: "SIM"} + for _, l := range layers { + simGroup.Sources = append(simGroup.Sources, int32(l.SSRC)) + } + entry.SSRCGroups = append(entry.SSRCGroups, simGroup) + // Build FID groups. + for _, l := range layers { + if l.FidSSRC != 0 { + entry.SSRCGroups = append(entry.SSRCGroups, ssrcGroupJSON{ + Semantics: "FID", + Sources: []int32{int32(l.SSRC), int32(l.FidSSRC)}, + }) + } + } + entries = append(entries, entry) + } + + if len(entries) == 0 { + continue + } + + msg := buildActiveVideoSSRCsMessage(entries) + if err := p.SendText(msg); err != nil { + s.log.Debugf("SendText video SSRCs to participant %d: %v", id, err) + } + } +} + +type videoSSRCEntry struct { + EndpointID string `json:"endpointId"` + SSRC int32 `json:"ssrc"` + SSRCGroups []ssrcGroupJSON `json:"ssrcGroups"` +} + +func buildActiveVideoSSRCsMessage(entries []videoSSRCEntry) string { + type msg struct { + ColibriClass string `json:"colibriClass"` + SSRCs []videoSSRCEntry `json:"ssrcs"` + } + data, _ := json.Marshal(msg{ColibriClass: "ActiveVideoSsrcs", SSRCs: entries}) + return string(data) +} + +func buildActiveSSRCsMessage(ssrcs []int32) string { + buf := []byte(`{"colibriClass":"ActiveAudioSsrcs","ssrcs":[`) + for i, ssrc := range ssrcs { + if i > 0 { + buf = append(buf, ',') + } + buf = append(buf, fmt.Sprintf("%d", ssrc)...) + } + buf = append(buf, ']', '}') + return string(buf) +} + +// Leave removes a participant from the SFU, closes their transport, +// and broadcasts updated SSRC lists to remaining participants. +func (s *SFU) Leave(participantID int) error { + s.mu.Lock() + p, ok := s.participants[participantID] + if !ok { + s.mu.Unlock() + return fmt.Errorf("participant %d not found", participantID) + } + + // Remove from participants map. + delete(s.participants, participantID) + + // Remove all SSRCs owned by this participant. + for ssrc, info := range s.ssrcRegistry { + if info.participantID == participantID { + delete(s.ssrcRegistry, ssrc) + } + } + + // Remove video SSRCs. + delete(s.videoSSRCs, participantID) + + // Remove RTX buffer for this sender. + delete(s.rtxBuffers, participantID) + delete(s.maxActiveLayer, participantID) + + // Stop and remove all layer selectors involving this participant. + var toStop []*LayerSelector + for key, ls := range s.layerSelectors { + if key[0] == participantID || key[1] == participantID { + toStop = append(toStop, ls) + delete(s.layerSelectors, key) + } + } + + // Remove TWCC generator. + var twccGen *TransportCCGenerator + if gen, ok := s.twccGenerators[participantID]; ok { + twccGen = gen + delete(s.twccGenerators, participantID) + } + + s.mu.Unlock() + + // Stop layer selectors outside the lock. + for _, ls := range toStop { + ls.Stop() + } + + // Stop TWCC generator outside the lock. + if twccGen != nil { + twccGen.Stop() + } + + // Close transport (outside lock — Close can block). + if err := p.Close(); err != nil { + s.log.Warnf("Error closing participant %d: %v", participantID, err) + } + + s.log.Infof("Participant %d left", participantID) + + // Broadcast updated SSRC lists to remaining participants. + s.broadcastActiveSSRCs() + s.broadcastActiveVideoSSRCs() + + return nil +} + +// Destroy closes all participants and cancels the SFU context. +func (s *SFU) Destroy() { + s.cancel() + s.mu.Lock() + // Stop all layer selectors before closing participants. + for _, ls := range s.layerSelectors { + ls.Stop() + } + s.layerSelectors = nil + // Stop all TWCC generators. + for _, gen := range s.twccGenerators { + gen.Stop() + } + s.twccGenerators = nil + for id, p := range s.participants { + if err := p.Close(); err != nil { + s.log.Warnf("Error closing participant %d: %v", id, err) + } + } + s.participants = nil + s.ssrcRegistry = nil + s.videoSSRCs = nil + s.rtxBuffers = nil + s.maxActiveLayer = nil + s.mu.Unlock() +} + +// iceCandidateToJSON converts a pion ICE candidate to our JSON format. +func iceCandidateToJSON(c ice.Candidate) candidateJSON { + return candidateJSON{ + Port: fmt.Sprintf("%d", c.Port()), + Protocol: "udp", + Network: "0", + Generation: "0", + ID: c.ID(), + Component: fmt.Sprintf("%d", c.Component()), + Foundation: c.Foundation(), + Priority: fmt.Sprintf("%d", c.Priority()), + IP: c.Address(), + Type: "host", + } +} + +// --- Global SFU registry --- + +var ( + sfuRegistry = make(map[int]*SFU) + sfuRegistryMu sync.Mutex + sfuNextID int32 +) + +// --- CGo exports --- + +//export GoSfu_Init +func GoSfu_Init() C.int { + fmt.Println("[GoSfu] Initialized") + return 0 +} + +//export GoSfu_Create +func GoSfu_Create() C.int { + handle := int(atomic.AddInt32(&sfuNextID, 1)) + sfu := NewSFU() + sfuRegistryMu.Lock() + sfuRegistry[handle] = sfu + sfuRegistryMu.Unlock() + fmt.Printf("[GoSfu] Created SFU handle=%d\n", handle) + return C.int(handle) +} + +//export GoSfu_Destroy +func GoSfu_Destroy(handle C.int) { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + if ok { + delete(sfuRegistry, h) + } + sfuRegistryMu.Unlock() + if ok { + sfu.Destroy() + fmt.Printf("[GoSfu] Destroyed SFU handle=%d\n", h) + } +} + +//export GoSfu_Join +func GoSfu_Join(handle C.int, participantID C.int, joinPayloadJSON *C.char, iceControlling C.int) *C.char { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + sfuRegistryMu.Unlock() + if !ok { + errMsg := fmt.Sprintf(`{"error":"unknown SFU handle %d"}`, h) + return C.CString(errMsg) + } + + payload := C.GoString(joinPayloadJSON) + resp, err := sfu.Join(int(participantID), payload, iceControlling != 0) + if err != nil { + errMsg := fmt.Sprintf(`{"error":"%s"}`, err.Error()) + return C.CString(errMsg) + } + return C.CString(resp) +} + +//export GoSfu_Leave +func GoSfu_Leave(handle C.int, participantID C.int) C.int { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + sfuRegistryMu.Unlock() + if !ok { + return -1 + } + if err := sfu.Leave(int(participantID)); err != nil { + fmt.Printf("[GoSfu] Leave error: %v\n", err) + return -1 + } + return 0 +} + +//export GoSfu_QuerySsrc +func GoSfu_QuerySsrc(handle C.int, ssrc C.uint) C.int { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + sfuRegistryMu.Unlock() + if !ok { + return -1 + } + return C.int(sfu.QuerySSRC(uint32(ssrc))) +} + +//export GoSfu_QueryVideoSsrcs +func GoSfu_QueryVideoSsrcs(handle C.int, participantID C.int) *C.char { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + sfuRegistryMu.Unlock() + if !ok { + return C.CString("[]") + } + return C.CString(sfu.QueryVideoSSRCs(int(participantID))) +} + +//export GoSfu_SetNetworkParams +func GoSfu_SetNetworkParams(handle C.int, participantID C.int, direction C.int, delayMs C.int, jitterMs C.int, dropRate C.double, bandwidthBps C.long) { + h := int(handle) + sfuRegistryMu.Lock() + sfu, ok := sfuRegistry[h] + sfuRegistryMu.Unlock() + if !ok { + return + } + sfu.SetNetworkParams(int(participantID), int(direction), int(delayMs), int(jitterMs), float64(dropRate), int64(bandwidthBps)) +} + +//export GoSfu_Free +func GoSfu_Free(ptr *C.char) { + C.free(unsafe.Pointer(ptr)) +} + +//export GoSfu_Shutdown +func GoSfu_Shutdown() { + sfuRegistryMu.Lock() + for h, sfu := range sfuRegistry { + sfu.Destroy() + delete(sfuRegistry, h) + } + sfuRegistryMu.Unlock() + fmt.Println("[GoSfu] Shutdown") +} + +func main() {} diff --git a/tools/go_sfu/twcc.go b/tools/go_sfu/twcc.go new file mode 100644 index 0000000000..2afc0487c2 --- /dev/null +++ b/tools/go_sfu/twcc.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/binary" + "sync" + "time" + + "github.com/pion/rtcp" +) + +// --- RTP Header Extension Parsing --- + +// parseTWCCSeq extracts the transport-wide sequence number from an RTP packet. +// extID is the header extension ID to look for (typically 3). +// Returns the sequence number and true if found, or 0 and false. +func parseTWCCSeq(pkt []byte, extID int) (uint16, bool) { + if len(pkt) < 12 { + return 0, false + } + + // Check extension bit (X) in RTP header byte 0. + if pkt[0]&0x10 == 0 { + return 0, false + } + + // Skip fixed header (12 bytes) + CSRC list. + cc := int(pkt[0] & 0x0F) + offset := 12 + cc*4 + if offset+4 > len(pkt) { + return 0, false + } + + // Check for one-byte header extension (0xBEDE magic). + if pkt[offset] != 0xBE || pkt[offset+1] != 0xDE { + return 0, false + } + + // Extension length in 32-bit words. + extLen := int(binary.BigEndian.Uint16(pkt[offset+2:])) * 4 + offset += 4 + extEnd := offset + extLen + if extEnd > len(pkt) { + return 0, false + } + + // Scan extension elements: [id:4][len:4][data...]. + for offset < extEnd { + b := pkt[offset] + if b == 0 { + // Padding byte. + offset++ + continue + } + id := int(b >> 4) + dataLen := int(b&0x0F) + 1 // len field is 0-based + offset++ + if id == extID && dataLen >= 2 && offset+2 <= extEnd { + seq := binary.BigEndian.Uint16(pkt[offset:]) + return seq, true + } + offset += dataLen + } + + return 0, false +} + +// --- Transport-CC Feedback Generator --- + +type twccArrival struct { + seq uint16 + arrivalUs int64 // microseconds since generator creation +} + +// TransportCCGenerator generates RTCP transport-cc feedback for a sender. +// It tracks packet arrivals and emits feedback every 100ms. +type TransportCCGenerator struct { + mu sync.Mutex + arrivals []twccArrival + startTime time.Time + fbCount uint8 // feedback packet counter + + // Callback to send the feedback RTCP packet. + sendFeedback func(data []byte) + + stopCh chan struct{} + done chan struct{} +} + +// NewTransportCCGenerator creates and starts a generator. +// sendFeedback is called with marshalled+encrypted RTCP data to send to the sender. +func NewTransportCCGenerator(sendFeedback func(data []byte)) *TransportCCGenerator { + g := &TransportCCGenerator{ + startTime: time.Now(), + sendFeedback: sendFeedback, + stopCh: make(chan struct{}), + done: make(chan struct{}), + } + go g.run() + return g +} + +// RecordArrival records a packet arrival. Thread-safe. +func (g *TransportCCGenerator) RecordArrival(twccSeq uint16) { + g.mu.Lock() + defer g.mu.Unlock() + arrivalUs := time.Since(g.startTime).Microseconds() + g.arrivals = append(g.arrivals, twccArrival{seq: twccSeq, arrivalUs: arrivalUs}) +} + +// Stop terminates the generator. +func (g *TransportCCGenerator) Stop() { + close(g.stopCh) + <-g.done +} + +func (g *TransportCCGenerator) run() { + defer close(g.done) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-g.stopCh: + return + case <-ticker.C: + g.emitFeedback() + } + } +} + +func (g *TransportCCGenerator) emitFeedback() { + g.mu.Lock() + if len(g.arrivals) == 0 { + g.mu.Unlock() + return + } + + // Take all arrivals. + arrivals := g.arrivals + g.arrivals = nil + g.fbCount++ + fbCount := g.fbCount + g.mu.Unlock() + + // Sort by sequence number (should already be mostly sorted). + for i := 1; i < len(arrivals); i++ { + for j := i; j > 0 && seqBefore(arrivals[j].seq, arrivals[j-1].seq); j-- { + arrivals[j], arrivals[j-1] = arrivals[j-1], arrivals[j] + } + } + + baseSeq := arrivals[0].seq + // Number of sequence numbers covered (including gaps). + lastSeq := arrivals[len(arrivals)-1].seq + packetCount := seqDiff(baseSeq, lastSeq) + 1 + + // Reference time: arrival of first packet in 64ms units. + refTimeUs := arrivals[0].arrivalUs + refTime := uint32(refTimeUs / 64000) // 64ms units, 24-bit in spec but stored as uint32 + + // Build received set for gap detection. + receivedAt := make(map[uint16]int64, len(arrivals)) + for _, a := range arrivals { + receivedAt[a.seq] = a.arrivalUs + } + + // Build packet chunks and recv deltas. + var chunks []rtcp.PacketStatusChunk + var deltas []*rtcp.RecvDelta + + // Process in runs of up to 7 (status vector chunk capacity for 2-bit symbols). + prevArrivalUs := refTimeUs + var statusList []uint16 + + seq := baseSeq + for i := 0; i < int(packetCount); i++ { + arrUs, received := receivedAt[seq] + if received { + deltaUs := arrUs - prevArrivalUs + if deltaUs >= 0 && deltaUs <= 63750 { // fits in small delta (0-255 * 250us) + statusList = append(statusList, rtcp.TypeTCCPacketReceivedSmallDelta) + deltas = append(deltas, &rtcp.RecvDelta{ + Type: rtcp.TypeTCCPacketReceivedSmallDelta, + Delta: deltaUs, + }) + } else { + statusList = append(statusList, rtcp.TypeTCCPacketReceivedLargeDelta) + deltas = append(deltas, &rtcp.RecvDelta{ + Type: rtcp.TypeTCCPacketReceivedLargeDelta, + Delta: deltaUs, + }) + } + prevArrivalUs = arrUs + } else { + statusList = append(statusList, rtcp.TypeTCCPacketNotReceived) + } + seq++ + } + + // Encode status list as status vector chunks (7 symbols per chunk with 2-bit symbols). + for i := 0; i < len(statusList); i += 7 { + end := i + 7 + if end > len(statusList) { + end = len(statusList) + } + chunk := statusList[i:end] + + // Check if all same status (use run-length). + allSame := true + for _, s := range chunk { + if s != chunk[0] { + allSame = false + break + } + } + + if allSame && len(chunk) >= 2 { + chunks = append(chunks, &rtcp.RunLengthChunk{ + Type: rtcp.TypeTCCRunLengthChunk, + PacketStatusSymbol: chunk[0], + RunLength: uint16(len(chunk)), + }) + } else { + // Status vector with 2-bit symbols. + symbolList := make([]uint16, len(chunk)) + copy(symbolList, chunk) + chunks = append(chunks, &rtcp.StatusVectorChunk{ + Type: rtcp.TypeTCCStatusVectorChunk, + SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit, + SymbolList: symbolList, + }) + } + } + + fb := &rtcp.TransportLayerCC{ + SenderSSRC: 1, + MediaSSRC: 0, + BaseSequenceNumber: baseSeq, + PacketStatusCount: packetCount, + ReferenceTime: refTime, + FbPktCount: fbCount, + PacketChunks: chunks, + RecvDeltas: deltas, + } + + data, err := rtcp.Marshal([]rtcp.Packet{fb}) + if err != nil { + return + } + + g.sendFeedback(data) +} + +// seqBefore returns true if a comes before b in the uint16 sequence space. +func seqBefore(a, b uint16) bool { + return int16(a-b) < 0 +} + +// seqDiff returns the forward distance from a to b in uint16 sequence space. +func seqDiff(a, b uint16) uint16 { + return b - a +} diff --git a/tools/tgcalls_cli/BUILD b/tools/tgcalls_cli/BUILD new file mode 100644 index 0000000000..294db47916 --- /dev/null +++ b/tools/tgcalls_cli/BUILD @@ -0,0 +1,37 @@ +cc_binary( + name = "tgcalls_cli", + srcs = [ + "main.cpp", + "group_mode.cpp", + "group_mode.h", + "group_participant.cpp", + "group_participant.h", + "group_churn_mode.cpp", + "group_churn_mode.h", + "fake_video_source.h", + "fake_video_source.cpp", + "fake_video_sink.h", + ], + copts = [ + "-I{}/tgcalls/tgcalls".format("submodules/TgVoipWebrtc"), + "-Ithird-party/webrtc/webrtc", + "-Ithird-party/webrtc/dependencies", + "-Ithird-party/webrtc/absl", + "-Ithird-party/libyuv", + "-DRTC_ENABLE_VP9", + "-DNDEBUG", + "-std=c++17", + "-w", + ] + select({ + "@platforms//os:linux": ["-DWEBRTC_LINUX", "-DWEBRTC_POSIX"], + "//conditions:default": ["-DWEBRTC_MAC", "-DWEBRTC_POSIX"], + }), + linkopts = select({ + "@platforms//os:linux": ["-lpthread", "-lm", "-ldl"], + "//conditions:default": ["-framework", "CoreFoundation", "-framework", "Security"], + }), + deps = [ + "//submodules/TgVoipWebrtc:tgcalls_core", + "//tools/go_sfu", + ], +) diff --git a/tools/tgcalls_cli/CLAUDE.md b/tools/tgcalls_cli/CLAUDE.md new file mode 100644 index 0000000000..b691ddf77c --- /dev/null +++ b/tools/tgcalls_cli/CLAUDE.md @@ -0,0 +1,41 @@ +# tgcalls CLI Test Tool + +In-process test harness for tgcalls. See the root `CLAUDE.md` for build instructions, top-level CLI usage, and the CLI options reference. + +## Supported Versions + +| Version | Implementation | Notes | +|---|---|---| +| `14.0.0` | `InstanceV2CompatImpl` | WebRTC PeerConnection + V2Impl signaling. Cross-version interop with 7.0.0–13.0.0 | +| `13.0.0` (default) | `InstanceV2Impl` | Also: 7.0.0, 8.0.0, 9.0.0, 12.0.0 | +| `11.0.0` | `InstanceV2ReferenceImpl` | Also: 10.0.0. Uses WebRTC PeerConnection | +| `5.0.0` | `InstanceImpl` (v1) | Also: 2.7.7. Legacy | + +## Architecture (P2P/Reflector) +- Two `tgcalls::Instance` objects (caller + callee) created via `Meta::Create(version, ...)` +- Signaling bridged via `SignalingBridge` with configurable drop rate and delay +- `FakeAudioDeviceModule` with `SineRecorder` (440Hz tone) and `NoOpRenderer` (audio discarded; validation via BWE) +- `FakeInterface` platform implementation (pure C++, no iOS/ObjC deps) +- Stats log validation: both caller and callee write `config.statsLogPath` with bitrate records; non-empty log with at least one non-zero BWE value is a success condition +- On failure, full tgcalls internal logs (caller + callee) are dumped to stdout via `config.logPath` + +## Architecture (Group) +- N participants using `GroupInstanceCustomImpl` and/or `GroupInstanceReferenceImpl` connect to an in-process Go SFU +- SFU uses Pion's low-level APIs (pion/ice, pion/dtls, pion/srtp, pion/sctp) — NOT PeerConnection +- ICE: lite mode, loopback-only, UDP host candidates on 127.0.0.1. SFU uses `Dial` (controlling) for CustomImpl clients and `Accept` (controlled) for PeerConnection clients +- DTLS: SFU acts as DTLS client (setup=active); GroupNetworkManager hardcodes SSL_SERVER for the tgcalls client +- SRTP: AES-256-GCM (negotiated via DTLS-SRTP; GroupNetworkManager requires GCM suites) +- SCTP: over DTLS, accepts data channel from client, reads Colibri messages, sends `ActiveAudioSsrcs` and `ActiveVideoSsrcs` notifications +- RTP forwarding: audio RTP forwarded to all others unconditionally; video RTP forwarded only to receivers that have requested video from that sender (via `ReceiverVideoConstraints`) +- SSRC tracking: SFU maintains `ssrcRegistry map[uint32]ssrcInfo` with kind (audio/video/video-rtx) and simulcast layer index, exposed via `GoSfu_QuerySsrc` and `GoSfu_QueryVideoSsrcs` CGo exports +- SSRC discovery: SFU broadcasts `ActiveAudioSsrcs` and `ActiveVideoSsrcs` over data channel when participants connect +- Video SSRC groups: parsed from join payload `"ssrc-groups"` field (SIM + FID semantics), stored per participant +- Colibri video constraints: SFU parses `ReceiverVideoConstraints` from receivers, sends `SenderVideoConstraints` back to senders with `idealHeight`, and sends proactive PLI to trigger keyframes when a receiver first requests video +- RTCP feedback: SFU demuxes SRTCP from the shared ICE transport (RFC 5761: byte[1] >= 200 && < 224), decrypts with per-participant SRTCP contexts, parses PLI/FIR, and forwards as new PLI to the sender. NACK is terminated (not forwarded). +- Audio validation: `audioLevelsUpdated` callback tracks remote audio levels; success requires every participant to receive audio from at least one other participant (remote SSRC != 0, level > 0.05). The 440Hz sine tone arrives at ~0.126 level after SFU forwarding. +- Video validation: `FakeVideoSink` (implements `rtc::VideoSinkInterface`) counts decoded frames per remote endpoint; success requires every participant to receive ≥1 frame from every other +- Video signaling flow: SFU broadcasts `ActiveVideoSsrcs` over data channel → `dataChannelMessageReceived` callback fires in the app → app calls `setRequestedVideoChannels` → CustomImpl creates `IncomingVideoChannel` / ReferenceImpl adds recvonly video transceiver → both send `ReceiverVideoConstraints` → SFU sends `SenderVideoConstraints` + proactive PLI → sender produces keyframe → receiver decodes +- `dataChannelMessageReceived` callback: added to `GroupInstanceDescriptor`, forwards all incoming Colibri data channel messages to the application. Used by the CLI test tool to react to `ActiveVideoSsrcs` and dynamically set up video channels — mirrors the real Telegram app's reactive flow +- `FakeAudioDeviceModule` with `SineRecorder` (440Hz tone) and `NoOpRenderer` — same as P2P mode +- `FakeVideoTrackSource` generates 1280x720 I420 frames at 30fps with per-participant color tint and frame counter (720p needed for 3 simulcast layers; 640x360 only allows 2 per WebRTC's `kSimulcastFormats`) +- Group mode source: `tools/tgcalls_cli/group_mode.cpp` diff --git a/tools/tgcalls_cli/fake_video_sink.h b/tools/tgcalls_cli/fake_video_sink.h new file mode 100644 index 0000000000..f54d0a5fc7 --- /dev/null +++ b/tools/tgcalls_cli/fake_video_sink.h @@ -0,0 +1,24 @@ +#pragma once +#include "api/video/video_frame.h" +#include "api/video/video_sink_interface.h" +#include + +class FakeVideoSink : public rtc::VideoSinkInterface { +public: + void OnFrame(const webrtc::VideoFrame& frame) override { + frameCount_.fetch_add(1, std::memory_order_relaxed); + int w = frame.width(); + int h = frame.height(); + lastWidth_.store(w, std::memory_order_relaxed); + lastHeight_.store(h, std::memory_order_relaxed); + } + int lastWidth() const { return lastWidth_.load(std::memory_order_relaxed); } + int lastHeight() const { return lastHeight_.load(std::memory_order_relaxed); } + int frameCount() const { + return frameCount_.load(std::memory_order_relaxed); + } +private: + std::atomic frameCount_{0}; + std::atomic lastWidth_{0}; + std::atomic lastHeight_{0}; +}; diff --git a/tools/tgcalls_cli/fake_video_source.cpp b/tools/tgcalls_cli/fake_video_source.cpp new file mode 100644 index 0000000000..597800303c --- /dev/null +++ b/tools/tgcalls_cli/fake_video_source.cpp @@ -0,0 +1,149 @@ +#include "fake_video_source.h" + +#include "api/video/video_frame.h" +#include "api/video/video_rotation.h" +#include "rtc_base/time_utils.h" + +#include + +namespace { + +constexpr int kWidth = 1280; +constexpr int kHeight = 720; +constexpr int kFps = 30; +constexpr uint8_t kBgY = 80; // dark background +constexpr uint8_t kDigitY = 235; // white digits +constexpr int kDigitW = 5; +constexpr int kDigitH = 7; +constexpr int kScale = 4; +constexpr int kDigitSpacing = 2; // pixels between digits (scaled) +constexpr int kMargin = 8; // top-left margin in pixels + +// 5x7 bitmap font for digits 0-9. Each entry is 7 rows of 5-bit patterns. +// MSB = leftmost pixel. +static const uint8_t kDigitBitmaps[10][7] = { + // 0 + {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110}, + // 1 + {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, + // 2 + {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, + // 3 + {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110}, + // 4 + {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010}, + // 5 + {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, + // 6 + {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, + // 7 + {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, + // 8 + {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, + // 9 + {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, +}; + +// 6 color tints cycling: red, green, blue, yellow, cyan, magenta +// UV values for each tint (in I420, U=Cb, V=Cr; neutral=128) +struct UVTint { uint8_t u; uint8_t v; }; +static const UVTint kTints[6] = { + {90, 240}, // red + {54, 34}, // green + {240, 110}, // blue + {16, 146}, // yellow + {166, 16}, // cyan + {166, 240}, // magenta +}; + +} // namespace + +FakeVideoTrackSource::FakeVideoTrackSource(int participantId) + : participantId_(participantId) { + const auto& tint = kTints[participantId % 6]; + uTint_ = tint.u; + vTint_ = tint.v; + thread_ = std::thread(&FakeVideoTrackSource::GenerateThread, this); +} + +FakeVideoTrackSource::~FakeVideoTrackSource() { + Stop(); +} + +rtc::scoped_refptr FakeVideoTrackSource::Create(int participantId) { + return rtc::scoped_refptr( + new rtc::RefCountedObject(participantId)); +} + +void FakeVideoTrackSource::Stop() { + if (running_.exchange(false)) { + if (thread_.joinable()) { + thread_.join(); + } + } +} + +void FakeVideoTrackSource::GenerateThread() { + int frameNumber = 0; + while (running_.load(std::memory_order_relaxed)) { + auto buffer = webrtc::I420Buffer::Create(kWidth, kHeight); + + // Fill Y plane with dark background + memset(buffer->MutableDataY(), kBgY, buffer->StrideY() * kHeight); + + // Fill U plane with tint + int uvHeight = (kHeight + 1) / 2; + memset(buffer->MutableDataU(), uTint_, buffer->StrideU() * uvHeight); + + // Fill V plane with tint + memset(buffer->MutableDataV(), vTint_, buffer->StrideV() * uvHeight); + + // Render frame counter digits + RenderDigits(buffer->MutableDataY(), buffer->StrideY(), frameNumber); + + auto frame = webrtc::VideoFrame::Builder() + .set_video_frame_buffer(buffer) + .set_rotation(webrtc::kVideoRotation_0) + .set_timestamp_us(rtc::TimeMicros()) + .build(); + + OnFrame(frame); + + ++frameNumber; + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / kFps)); + } +} + +void FakeVideoTrackSource::RenderDigits(uint8_t* yPlane, int strideY, int frameNumber) { + // Convert frame number to decimal digits + char numStr[16]; + snprintf(numStr, sizeof(numStr), "%d", frameNumber); + int numDigits = static_cast(strlen(numStr)); + + int xOffset = kMargin; + for (int d = 0; d < numDigits; ++d) { + int digit = numStr[d] - '0'; + const uint8_t* bitmap = kDigitBitmaps[digit]; + + for (int row = 0; row < kDigitH; ++row) { + uint8_t rowBits = bitmap[row]; + for (int col = 0; col < kDigitW; ++col) { + if (rowBits & (1 << (kDigitW - 1 - col))) { + // Fill scaled pixel block + int px = xOffset + col * kScale; + int py = kMargin + row * kScale; + for (int sy = 0; sy < kScale; ++sy) { + for (int sx = 0; sx < kScale; ++sx) { + int x = px + sx; + int y = py + sy; + if (x < kWidth && y < kHeight) { + yPlane[y * strideY + x] = kDigitY; + } + } + } + } + } + } + xOffset += kDigitW * kScale + kDigitSpacing; + } +} diff --git a/tools/tgcalls_cli/fake_video_source.h b/tools/tgcalls_cli/fake_video_source.h new file mode 100644 index 0000000000..b17bd2b6a6 --- /dev/null +++ b/tools/tgcalls_cli/fake_video_source.h @@ -0,0 +1,38 @@ +#pragma once + +#include "api/video/i420_buffer.h" +#include "media/base/adapted_video_track_source.h" +#include "rtc_base/ref_counted_object.h" + +#include +#include + +// Generates 640x360 I420 frames at 30fps with per-participant color tint +// and an incrementing frame counter rendered as block digits. +class FakeVideoTrackSource : public rtc::AdaptedVideoTrackSource { +public: + static rtc::scoped_refptr Create(int participantId); + + ~FakeVideoTrackSource() override; + + void Stop(); + + // VideoTrackSourceInterface + SourceState state() const override { return kLive; } + bool remote() const override { return false; } + bool is_screencast() const override { return false; } + absl::optional needs_denoising() const override { return false; } + +protected: + explicit FakeVideoTrackSource(int participantId); + +private: + void GenerateThread(); + void RenderDigits(uint8_t* yPlane, int strideY, int frameNumber); + + int participantId_; + uint8_t uTint_; + uint8_t vTint_; + std::atomic running_{true}; + std::thread thread_; +}; diff --git a/tools/tgcalls_cli/group_churn_mode.cpp b/tools/tgcalls_cli/group_churn_mode.cpp new file mode 100644 index 0000000000..05001f6bb8 --- /dev/null +++ b/tools/tgcalls_cli/group_churn_mode.cpp @@ -0,0 +1,205 @@ +#include "group_churn_mode.h" +#include "group_participant.h" + +#include +#include +#include +#include + +// CGo header +#include "tools/go_sfu/go_sfu.h" + +int runGroupChurnMode( + int customParticipants, + int referenceParticipants, + int duration, + bool quiet, + bool video, + int churnCycles +) { + gGroupQuiet = quiet; + gGroupStartTime = std::chrono::steady_clock::now(); + + int baseCount = customParticipants + referenceParticipants; + if (baseCount < 2) { + fprintf(stderr, "Error: need at least 2 base participants total\n"); + return 1; + } + + groupLog("Churn", "initializing Go SFU..."); + + int rc = GoSfu_Init(); + if (rc != 0) { + fprintf(stderr, "Error: GoSfu_Init failed with %d\n", rc); + return 1; + } + + GoInt sfuHandle = GoSfu_Create(); + if (sfuHandle <= 0) { + fprintf(stderr, "Error: GoSfu_Create failed\n"); + return 1; + } + + groupLog("Churn", "SFU handle=%lld, base=%d (custom=%d, ref=%d), cycles=%d, video=%s", + (long long)sfuHandle, baseCount, customParticipants, referenceParticipants, + churnCycles, video ? "yes" : "no"); + + auto threads = tgcalls::StaticThreads::getThreads(); + + // --- Phase 1: Create base group --- + groupLog("Churn", "creating base group..."); + std::vector> baseStates; + bool anyFailed = false; + + for (int i = 0; i < baseCount; ++i) { + bool isReference = (i >= customParticipants); + auto state = createParticipant(i, isReference, sfuHandle, threads, quiet, video, &baseStates); + if (!state) { + anyFailed = true; + continue; + } + baseStates.push_back(std::move(state)); + } + + // Wait for all base participants to connect + groupLog("Churn", "waiting for base group connections..."); + auto waitStart = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(15)) { + int connectedCount = 0; + for (const auto& s : baseStates) { + if (s->wasConnected.load()) connectedCount++; + } + if (connectedCount == (int)baseStates.size()) { + groupLog("Churn", "all %d base participants connected", (int)baseStates.size()); + break; + } + groupLog("Churn", "base connected: %d/%d", connectedCount, (int)baseStates.size()); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + // Wait for audio to flow in base group + groupLog("Churn", "waiting for base group audio..."); + waitStart = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(10)) { + int audioCount = 0; + for (const auto& s : baseStates) { + if (s->receivedAudio.load()) audioCount++; + } + if (audioCount == (int)baseStates.size()) { + groupLog("Churn", "all %d base participants receiving audio", (int)baseStates.size()); + break; + } + groupLog("Churn", "base audio: %d/%d", audioCount, (int)baseStates.size()); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + // --- Phase 2: Churn loop --- + groupLog("Churn", "starting churn: %d cycles", churnCycles); + int nextId = baseCount; + int completedCycles = 0; + + for (int cycle = 0; cycle < churnCycles; ++cycle) { + bool isReference = (cycle % 2 == 1); + int churnId = nextId++; + + auto churner = createParticipant(churnId, isReference, sfuHandle, threads, quiet, video, &baseStates); + if (!churner) { + groupLog("Churn", "cycle %d: createParticipant failed for id=%d", cycle, churnId); + anyFailed = true; + continue; + } + + // Wait briefly for connection (up to 3s) + auto connStart = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - connStart < std::chrono::seconds(3)) { + if (churner->wasConnected.load()) break; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + if (!churner->wasConnected.load()) { + groupLog("Churn", "cycle %d: churner %d did not connect (continuing anyway)", cycle, churnId); + } + + // Leave + stopParticipant(churner.get(), sfuHandle); + completedCycles++; + + if ((cycle + 1) % 10 == 0) { + groupLog("Churn", "progress: %d/%d cycles completed", cycle + 1, churnCycles); + } + } + + groupLog("Churn", "churn complete: %d/%d cycles succeeded", completedCycles, churnCycles); + + // --- Phase 3: Stabilize and validate --- + groupLog("Churn", "stabilizing for %d seconds...", duration); + std::this_thread::sleep_for(std::chrono::seconds(duration)); + + auto result = validateGroupState(baseStates, video); + + // --- Phase 4: Teardown --- + groupLog("Churn", "stopping base participants..."); + + // Stop video sources + for (auto& s : baseStates) { + if (s->videoSource) { + s->videoSource->Stop(); + } + } + + // Stop instances + std::atomic stopCount{0}; + std::mutex stopMutex; + std::condition_variable stopCv; + + for (const auto& s : baseStates) { + if (s->instance) { + int pid_local = s->id; + s->instance->stop([&stopCount, &stopMutex, &stopCv, pid_local]() { + groupLog("Churn", "base participant %d stopped", pid_local); + stopCount.fetch_add(1); + std::lock_guard lock(stopMutex); + stopCv.notify_all(); + }); + } + } + + { + std::unique_lock lock(stopMutex); + stopCv.wait_for(lock, std::chrono::seconds(5), [&] { + return stopCount.load() >= (int)baseStates.size(); + }); + } + + for (auto& s : baseStates) { + s->instance.reset(); + } + + GoSfu_Destroy(sfuHandle); + GoSfu_Shutdown(); + + // Print summary + bool success = result.success && !anyFailed && (completedCycles == churnCycles); + + printf("\n=== Group Churn Test Summary ===\n"); + printf("Base participants: %d (custom=%d, reference=%d)\n", + baseCount, customParticipants, referenceParticipants); + printf("Churn cycles: %d/%d completed\n", completedCycles, churnCycles); + printf("Video: %s\n", video ? "yes" : "no"); + printf("Stabilization: %ds\n", duration); + printf("Base connected: %d/%d\n", result.connectedCount, result.totalParticipants); + printf("Base audio received: %d/%d\n", result.audioReceivedCount, result.totalParticipants); + if (video) { + printf("Base video received: %d/%d\n", result.videoReceivedPairs, result.videoExpectedPairs); + } + printf("Result: %s\n", success ? "SUCCESS" : "FAILED"); + + // Clean up log files + for (const auto& s : baseStates) { + unlink(s->logPath.c_str()); + } + + fflush(stdout); + fflush(stderr); + _exit(success ? 0 : 1); +} diff --git a/tools/tgcalls_cli/group_churn_mode.h b/tools/tgcalls_cli/group_churn_mode.h new file mode 100644 index 0000000000..a42c2b203e --- /dev/null +++ b/tools/tgcalls_cli/group_churn_mode.h @@ -0,0 +1,10 @@ +#pragma once + +int runGroupChurnMode( + int customParticipants, + int referenceParticipants, + int duration, + bool quiet, + bool video, + int churnCycles +); diff --git a/tools/tgcalls_cli/group_mode.cpp b/tools/tgcalls_cli/group_mode.cpp new file mode 100644 index 0000000000..4dd6c4e0d6 --- /dev/null +++ b/tools/tgcalls_cli/group_mode.cpp @@ -0,0 +1,173 @@ +#include "group_mode.h" +#include "group_participant.h" + +#include +#include +#include +#include +#include +#include + +// CGo header +#include "tools/go_sfu/go_sfu.h" + +int runGroupMode(int customParticipants, int referenceParticipants, int duration, bool quiet, bool video, const std::string& networkScenario) { + gGroupQuiet = quiet; + gGroupStartTime = std::chrono::steady_clock::now(); + + int participants = customParticipants + referenceParticipants; + if (participants < 2) { + fprintf(stderr, "Error: need at least 2 participants total\n"); + return 1; + } + + groupLog("Group", "initializing Go SFU..."); + + int rc = GoSfu_Init(); + if (rc != 0) { + fprintf(stderr, "Error: GoSfu_Init failed with %d\n", rc); + return 1; + } + + GoInt sfuHandle = GoSfu_Create(); + if (sfuHandle <= 0) { + fprintf(stderr, "Error: GoSfu_Create failed\n"); + return 1; + } + + groupLog("Group", "created SFU handle=%lld, custom=%d, reference=%d, duration=%ds", + (long long)sfuHandle, customParticipants, referenceParticipants, duration); + + auto threads = tgcalls::StaticThreads::getThreads(); + + // Create participants + std::vector> states; + bool anyFailed = false; + + for (int i = 0; i < participants; ++i) { + bool isReference = (i >= customParticipants); + auto state = createParticipant(i, isReference, sfuHandle, threads, quiet, video, &states); + if (!state) { + anyFailed = true; + continue; + } + states.push_back(std::move(state)); + } + + // Wait for all participants to connect + groupLog("Group", "waiting for connections..."); + bool allConnected = false; + auto waitStart = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(15)) { + int connectedCount = 0; + for (const auto& s : states) { + if (s->wasConnected.load()) connectedCount++; + } + if (connectedCount == (int)states.size()) { + allConnected = true; + groupLog("Group", "all %d participants connected", (int)states.size()); + break; + } + groupLog("Group", "connected: %d/%d", connectedCount, (int)states.size()); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + if (!allConnected) { + int connectedCount = 0; + for (const auto& s : states) { + if (s->wasConnected.load()) connectedCount++; + } + groupLog("Group", "connection timeout: %d/%d connected", connectedCount, (int)states.size()); + } + + // Run for the specified duration, optionally with network scenario. + if (!networkScenario.empty() && networkScenario == "step-down-up") { + // Scenario: start uncapped, then step down, step up, uncap. + // Split duration into 4 phases. + int phase = std::max(duration / 4, 2); + groupLog("Group", "network-scenario '%s': phase duration=%ds", networkScenario.c_str(), phase); + + // Phase 1: uncapped (should be layer 2 on high BW). + groupLog("Group", "phase 1: uncapped"); + std::this_thread::sleep_for(std::chrono::seconds(phase)); + + // Phase 2: cap to 80 kbps (should force downswitch to layer 0). + groupLog("Group", "phase 2: cap 80kbps"); + for (const auto& s : states) { + GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 80000); + } + std::this_thread::sleep_for(std::chrono::seconds(phase)); + + // Phase 3: cap to 200 kbps (should allow upswitch to layer 1). + groupLog("Group", "phase 3: cap 200kbps"); + for (const auto& s : states) { + GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 200000); + } + std::this_thread::sleep_for(std::chrono::seconds(phase)); + + // Phase 4: uncap (should allow upswitch to layer 2). + groupLog("Group", "phase 4: uncapped"); + for (const auto& s : states) { + GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 0); + } + std::this_thread::sleep_for(std::chrono::seconds(phase)); + } else { + groupLog("Group", "running for %d seconds...", duration); + std::this_thread::sleep_for(std::chrono::seconds(duration)); + } + + // Stop all participants (using GoSfu_Destroy for bulk teardown) + groupLog("Group", "stopping participants..."); + + // Stop video sources first + for (auto& s : states) { + if (s->videoSource) { + s->videoSource->Stop(); + } + } + + // Stop instances + std::atomic stopCount{0}; + std::mutex stopMutex; + std::condition_variable stopCv; + + for (const auto& s : states) { + if (s->instance) { + int pid_local = s->id; + s->instance->stop([&stopCount, &stopMutex, &stopCv, pid_local]() { + groupLog("Group", "participant %d stopped", pid_local); + stopCount.fetch_add(1); + std::lock_guard lock(stopMutex); + stopCv.notify_all(); + }); + } + } + + { + std::unique_lock lock(stopMutex); + stopCv.wait_for(lock, std::chrono::seconds(5), [&] { + return stopCount.load() >= (int)states.size(); + }); + } + + for (auto& s : states) { + s->instance.reset(); + } + + // Destroy SFU + GoSfu_Destroy(sfuHandle); + GoSfu_Shutdown(); + + // Validate and print summary + auto result = validateGroupState(states, video); + bool success = printGroupSummary(customParticipants, referenceParticipants, duration, video, result, anyFailed); + + // Clean up log files + for (const auto& s : states) { + unlink(s->logPath.c_str()); + } + + fflush(stdout); + fflush(stderr); + _exit(success ? 0 : 1); +} diff --git a/tools/tgcalls_cli/group_mode.h b/tools/tgcalls_cli/group_mode.h new file mode 100644 index 0000000000..547b7e15fd --- /dev/null +++ b/tools/tgcalls_cli/group_mode.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +int runGroupMode(int customParticipants, int referenceParticipants, int duration, bool quiet, bool video, const std::string& networkScenario = ""); diff --git a/tools/tgcalls_cli/group_participant.cpp b/tools/tgcalls_cli/group_participant.cpp new file mode 100644 index 0000000000..7960532efe --- /dev/null +++ b/tools/tgcalls_cli/group_participant.cpp @@ -0,0 +1,442 @@ +#include "group_participant.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third-party/json11.hpp" + +// --------------------------------------------------------------------------- +// Globals +// --------------------------------------------------------------------------- + +std::chrono::steady_clock::time_point gGroupStartTime = std::chrono::steady_clock::now(); +std::atomic gGroupQuiet{false}; + +double groupElapsed() { + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration(now - gGroupStartTime).count(); +} + +void groupLog(const char* tag, const char* fmt, ...) { + if (gGroupQuiet) return; + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + fprintf(stderr, "[%7.3f] %s: %s\n", groupElapsed(), tag, buf); +} + +// --------------------------------------------------------------------------- +// GroupSineRecorder +// --------------------------------------------------------------------------- + +GroupSineRecorder::GroupSineRecorder() { + buffer_.resize(kFrameSamples * kChannels); +} + +tgcalls::AudioFrame GroupSineRecorder::Record() { + for (size_t i = 0; i < kFrameSamples; ++i) { + double t = static_cast(phase_) / kSampleRate; + int16_t sample = static_cast(kAmplitude * std::sin(2.0 * M_PI * kFrequency * t)); + for (size_t ch = 0; ch < kChannels; ++ch) { + buffer_[i * kChannels + ch] = sample; + } + ++phase_; + } + + tgcalls::AudioFrame frame; + frame.audio_samples = buffer_.data(); + frame.num_samples = kFrameSamples; + frame.bytes_per_sample = sizeof(int16_t); + frame.num_channels = kChannels; + frame.samples_per_sec = kSampleRate; + frame.elapsed_time_ms = 0; + frame.ntp_time_ms = 0; + return frame; +} + +int32_t GroupSineRecorder::WaitForUs() { + return 10000; // 10ms +} + +// --------------------------------------------------------------------------- +// GroupNoOpRenderer +// --------------------------------------------------------------------------- + +bool GroupNoOpRenderer::Render(const tgcalls::AudioFrame&) { return true; } + +// --------------------------------------------------------------------------- +// SimpleRequestMediaChannelDescriptionTask +// --------------------------------------------------------------------------- + +void SimpleRequestMediaChannelDescriptionTask::cancel() {} + +// --------------------------------------------------------------------------- +// createParticipant +// --------------------------------------------------------------------------- + +std::unique_ptr createParticipant( + int id, + bool isReference, + GoInt sfuHandle, + std::shared_ptr threads, + bool quiet, + bool video, + std::vector>* allStates +) { + auto state = std::make_unique(); + state->id = id; + state->isReference = isReference; + state->logPath = "/tmp/tgcalls_group_p" + std::to_string(id) + "_" + std::to_string(getpid()) + ".log"; + + std::string tag = "P" + std::to_string(id); + + auto recorder = std::make_shared(); + auto renderer = std::make_shared(); + + ParticipantState* statePtr = state.get(); + GoInt sfuH = sfuHandle; + + tgcalls::GroupInstanceDescriptor descriptor; + descriptor.threads = threads; + descriptor.config.need_log = true; + descriptor.config.logPath = {state->logPath}; + descriptor.networkStateUpdated = [statePtr, tag](tgcalls::GroupNetworkState networkState) { + groupLog(tag.c_str(), "network state: connected=%s", networkState.isConnected ? "true" : "false"); + statePtr->connected.store(networkState.isConnected); + if (networkState.isConnected) { + statePtr->wasConnected.store(true); + } + }; + descriptor.audioLevelsUpdated = [statePtr, tag](tgcalls::GroupLevelsUpdate const &update) { + for (const auto& level : update.updates) { + if (level.value.level > 0.01f) { + groupLog(tag.c_str(), "audio level: ssrc=%u level=%.3f voice=%d", + level.ssrc, level.value.level, level.value.voice); + } + if (level.ssrc != 0 && level.value.level > 0.05f) { + statePtr->receivedAudio.store(true); + } + } + }; + descriptor.createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( + renderer, recorder, + tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} + ); + + descriptor.requestMediaChannelDescriptions = [sfuH, tag, allStates]( + std::vector const &ssrcs, + std::function &&)> callback + ) -> std::shared_ptr { + std::set audioSsrcs; + for (const auto& s : *allStates) { + if (s->audioSsrc != 0) audioSsrcs.insert(s->audioSsrc); + } + std::vector descriptions; + for (uint32_t ssrc : ssrcs) { + GoInt ownerID = GoSfu_QuerySsrc(sfuH, (GoUint)ssrc); + bool isAudio = audioSsrcs.count(ssrc) > 0; + groupLog(tag.c_str(), "requestMediaChannelDescriptions: ssrc=%u -> owner=%lld type=%s", + ssrc, (long long)ownerID, isAudio ? "audio" : "video"); + tgcalls::MediaChannelDescription desc; + desc.type = isAudio ? tgcalls::MediaChannelDescription::Type::Audio + : tgcalls::MediaChannelDescription::Type::Video; + desc.audioSsrc = ssrc; + desc.userId = ownerID; + descriptions.push_back(std::move(desc)); + } + callback(std::move(descriptions)); + return std::make_shared(); + }; + + descriptor.outgoingAudioBitrateKbit = 32; + descriptor.disableIncomingChannels = false; + descriptor.useDummyChannel = true; + + // Video configuration + if (video) { + auto videoSource = FakeVideoTrackSource::Create(id); + state->videoSource = videoSource; + state->endpointId = std::to_string(id); + descriptor.videoContentType = tgcalls::VideoContentType::Generic; + descriptor.videoCodecPreferences = {tgcalls::VideoCodecName::H264}; + // Set the outgoing video min bitrate to 600 kbps so the sender's + // BWE floor is high enough to activate all 3 simulcast layers + // (audio 32k + L0 min 50k + L1 min 100k + L2 min 300k = 482k). + // On localhost, delay-based BWE over the loopback pacer has been + // observed to drift down to ~80 kbps, keeping L2 disabled. Clamping + // the min forces the encoder to keep L2 producing. + descriptor.minOutgoingVideoBitrateKbit = 600; + descriptor.getVideoSource = [videoSource]() -> webrtc::scoped_refptr { + return videoSource; + }; + + descriptor.dataChannelMessageReceived = [statePtr, sfuH, tag](std::string const &message) { + std::string parseErr; + auto json = json11::Json::parse(message, parseErr); + if (!parseErr.empty() || !json.is_object()) return; + auto cls = json["colibriClass"].string_value(); + if (cls != "ActiveVideoSsrcs") return; + + auto ssrcsArray = json["ssrcs"].array_items(); + if (ssrcsArray.empty()) return; + + std::vector videoChannels; + for (const auto& entry : ssrcsArray) { + std::string endpointId = entry["endpointId"].string_value(); + if (endpointId == statePtr->endpointId) continue; + + { + std::lock_guard lock(statePtr->videoSinksMutex); + if (statePtr->videoSinks.count(endpointId) > 0) continue; + } + + int remoteId = 0; + if (sscanf(endpointId.c_str(), "%d", &remoteId) != 1) continue; + + char* ssrcsRaw = GoSfu_QueryVideoSsrcs(sfuH, (GoInt)remoteId); + if (!ssrcsRaw) continue; + std::string ssrcsJson(ssrcsRaw); + GoSfu_Free(ssrcsRaw); + + std::string err2; + auto layers = json11::Json::parse(ssrcsJson, err2); + if (!err2.empty() || !layers.is_array() || layers.array_items().empty()) continue; + + tgcalls::VideoChannelDescription desc; + desc.audioSsrc = 0; + desc.userId = remoteId; + desc.endpointId = endpointId; + desc.maxQuality = tgcalls::VideoChannelDescription::Quality::Full; + desc.minQuality = tgcalls::VideoChannelDescription::Quality::Full; + + tgcalls::MediaSsrcGroup simGroup; + simGroup.semantics = "SIM"; + for (const auto& layer : layers.array_items()) { + uint32_t ssrc = static_cast(static_cast(layer["ssrc"].number_value())); + uint32_t fidSsrc = static_cast(static_cast(layer["fidSsrc"].number_value())); + if (ssrc == 0) continue; + simGroup.ssrcs.push_back(ssrc); + if (fidSsrc != 0) { + tgcalls::MediaSsrcGroup fidGroup; + fidGroup.semantics = "FID"; + fidGroup.ssrcs = {ssrc, fidSsrc}; + desc.ssrcGroups.push_back(std::move(fidGroup)); + } + } + desc.ssrcGroups.insert(desc.ssrcGroups.begin(), std::move(simGroup)); + videoChannels.push_back(std::move(desc)); + + auto sink = std::make_shared(); + { + std::lock_guard lock(statePtr->videoSinksMutex); + statePtr->videoSinks[endpointId] = sink; + } + statePtr->instance->addIncomingVideoOutput( + endpointId, + std::weak_ptr>(sink)); + + groupLog(tag.c_str(), "ActiveVideoSsrcs: adding video channel for endpoint %s", endpointId.c_str()); + } + + if (!videoChannels.empty()) { + statePtr->instance->setRequestedVideoChannels(std::move(videoChannels)); + } + }; + } else { + descriptor.videoContentType = tgcalls::VideoContentType::None; + } + + // Create instance + if (isReference) { + state->instance = std::make_unique(std::move(descriptor)); + groupLog(tag.c_str(), "created GroupInstanceReferenceImpl"); + } else { + state->instance = std::make_unique(std::move(descriptor)); + groupLog(tag.c_str(), "created GroupInstanceCustomImpl"); + } + + // Set connection mode + state->instance->setConnectionMode( + tgcalls::GroupConnectionMode::GroupConnectionModeRtc, false, false); + + // Emit join payload + std::mutex joinMutex; + std::condition_variable joinCv; + bool joinReady = false; + std::string joinJson; + uint32_t joinSsrc = 0; + + state->instance->emitJoinPayload([&](tgcalls::GroupJoinPayload const &payload) { + std::lock_guard lock(joinMutex); + joinJson = payload.json; + joinSsrc = payload.audioSsrc; + joinReady = true; + joinCv.notify_one(); + }); + + { + std::unique_lock lock(joinMutex); + if (!joinCv.wait_for(lock, std::chrono::seconds(5), [&] { return joinReady; })) { + fprintf(stderr, "Error: emitJoinPayload timed out for participant %d\n", id); + return nullptr; + } + } + + state->audioSsrc = joinSsrc; + groupLog(tag.c_str(), "join payload ready: ssrc=%u, json=%zu bytes", joinSsrc, joinJson.size()); + + // Join SFU + GoInt iceControlling = isReference ? 0 : 1; + char* responseRaw = GoSfu_Join(sfuHandle, (GoInt)id, const_cast(joinJson.c_str()), iceControlling); + if (!responseRaw) { + fprintf(stderr, "Error: GoSfu_Join returned null for participant %d\n", id); + return nullptr; + } + std::string response(responseRaw); + GoSfu_Free(responseRaw); + + if (response.find("\"error\"") != std::string::npos) { + fprintf(stderr, "Error: GoSfu_Join failed for participant %d: %s\n", id, response.c_str()); + return nullptr; + } + + groupLog(tag.c_str(), "SFU join response: %zu bytes", response.size()); + + state->instance->setJoinResponsePayload(response); + state->instance->setIsMuted(false); + + groupLog(tag.c_str(), "joined and unmuted"); + return state; +} + +// --------------------------------------------------------------------------- +// stopParticipant +// --------------------------------------------------------------------------- + +void stopParticipant(ParticipantState* state, GoInt sfuHandle) { + if (!state || !state->instance) return; + + std::string tag = "P" + std::to_string(state->id); + + // Remove from SFU first so broadcasts go out to remaining participants. + GoInt rc = GoSfu_Leave(sfuHandle, (GoInt)state->id); + if (rc != 0) { + groupLog(tag.c_str(), "GoSfu_Leave returned %lld (may already be removed)", (long long)rc); + } + + // Stop video source. + if (state->videoSource) { + state->videoSource->Stop(); + } + + // Stop instance with timeout. Heap-allocate sync state so the stop callback + // is safe even if it fires after the 5s timeout (avoids stack-frame UB). + struct StopState { + std::mutex mu; + std::condition_variable cv; + std::atomic done{false}; + }; + auto stopState = std::make_shared(); + + state->instance->stop([stopState]() { + stopState->done.store(true); + std::lock_guard lock(stopState->mu); + stopState->cv.notify_all(); + }); + + { + std::unique_lock lock(stopState->mu); + stopState->cv.wait_for(lock, std::chrono::seconds(5), [&] { return stopState->done.load(); }); + } + + state->instance.reset(); + + // Clean up log file. + unlink(state->logPath.c_str()); + + groupLog(tag.c_str(), "stopped and cleaned up"); +} + +// --------------------------------------------------------------------------- +// validateGroupState +// --------------------------------------------------------------------------- + +GroupValidationResult validateGroupState( + const std::vector>& states, + bool video +) { + GroupValidationResult result{}; + result.totalParticipants = static_cast(states.size()); + + for (const auto& s : states) { + if (s->wasConnected.load()) result.connectedCount++; + if (s->receivedAudio.load()) result.audioReceivedCount++; + } + + if (video) { + int videoParticipants = 0; + for (const auto& s : states) { + if (s->videoSource) videoParticipants++; + } + result.videoExpectedPairs = videoParticipants * (videoParticipants - 1); + + for (const auto& s : states) { + std::lock_guard lock(s->videoSinksMutex); + for (const auto& [endpointId, sink] : s->videoSinks) { + int frames = sink->frameCount(); + if (frames > 0) { + result.videoReceivedPairs++; + } + groupLog("Validate", "P%d <- endpoint %s: %d video frames (%dx%d)", + s->id, endpointId.c_str(), frames, + sink->lastWidth(), sink->lastHeight()); + } + } + } + + result.success = (result.connectedCount == result.totalParticipants && + result.audioReceivedCount == result.totalParticipants); + if (video && result.videoExpectedPairs > 0) { + result.success = result.success && (result.videoReceivedPairs >= result.videoExpectedPairs); + } + + return result; +} + +// --------------------------------------------------------------------------- +// printGroupSummary +// --------------------------------------------------------------------------- + +bool printGroupSummary( + int customParticipants, + int referenceParticipants, + int duration, + bool video, + const GroupValidationResult& result, + bool anyFailed +) { + bool success = result.success && !anyFailed; + + printf("\n=== Group Call Summary ===\n"); + printf("Custom participants: %d\n", customParticipants); + printf("Reference participants: %d\n", referenceParticipants); + printf("Total participants: %d\n", result.totalParticipants); + printf("Duration: %ds\n", duration); + printf("SFU: Go/Pion (in-process)\n"); + printf("Connected: %d/%d\n", result.connectedCount, result.totalParticipants); + printf("Audio received: %d/%d\n", result.audioReceivedCount, result.totalParticipants); + if (video) { + printf("Video received: %d/%d\n", result.videoReceivedPairs, result.videoExpectedPairs); + } + printf("Result: %s\n", success ? "SUCCESS" : "FAILED"); + + return success; +} diff --git a/tools/tgcalls_cli/group_participant.h b/tools/tgcalls_cli/group_participant.h new file mode 100644 index 0000000000..8f3e1efb09 --- /dev/null +++ b/tools/tgcalls_cli/group_participant.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "group/GroupInstanceCustomImpl.h" +#include "group/GroupInstanceImpl.h" +#include "group/GroupInstanceReferenceImpl.h" +#include "FakeAudioDeviceModule.h" +#include "StaticThreads.h" +#include "AudioFrame.h" +#include "fake_video_source.h" +#include "fake_video_sink.h" + +// CGo header +#include "tools/go_sfu/go_sfu.h" + +// --------------------------------------------------------------------------- +// Logging helpers +// --------------------------------------------------------------------------- + +extern std::chrono::steady_clock::time_point gGroupStartTime; +extern std::atomic gGroupQuiet; + +double groupElapsed(); +void groupLog(const char* tag, const char* fmt, ...); + +// --------------------------------------------------------------------------- +// GroupSineRecorder - generates 440 Hz sine tone +// --------------------------------------------------------------------------- + +class GroupSineRecorder : public tgcalls::FakeAudioDeviceModule::Recorder { +public: + GroupSineRecorder(); + tgcalls::AudioFrame Record() override; + int32_t WaitForUs() override; + +private: + static constexpr size_t kSampleRate = 48000; + static constexpr size_t kChannels = 2; + static constexpr size_t kFrameSamples = 480; + static constexpr double kFrequency = 440.0; + static constexpr double kAmplitude = 3000.0; + + std::vector buffer_; + uint64_t phase_ = 0; +}; + +// --------------------------------------------------------------------------- +// GroupNoOpRenderer - discards received audio +// --------------------------------------------------------------------------- + +class GroupNoOpRenderer : public tgcalls::FakeAudioDeviceModule::Renderer { +public: + bool Render(const tgcalls::AudioFrame&) override; +}; + +// --------------------------------------------------------------------------- +// SimpleRequestMediaChannelDescriptionTask +// --------------------------------------------------------------------------- + +class SimpleRequestMediaChannelDescriptionTask : public tgcalls::RequestMediaChannelDescriptionTask { +public: + void cancel() override; +}; + +// --------------------------------------------------------------------------- +// ParticipantState +// --------------------------------------------------------------------------- + +struct ParticipantState { + int id; + bool isReference; + std::unique_ptr instance; + std::atomic connected{false}; + std::atomic wasConnected{false}; + std::atomic receivedAudio{false}; + uint32_t audioSsrc{0}; + std::string logPath; + + // Video fields + std::string endpointId; + rtc::scoped_refptr videoSource; + std::mutex videoSinksMutex; + std::map> videoSinks; +}; + +// --------------------------------------------------------------------------- +// GroupValidationResult +// --------------------------------------------------------------------------- + +struct GroupValidationResult { + int totalParticipants; + int connectedCount; + int audioReceivedCount; + int videoReceivedPairs; + int videoExpectedPairs; + bool success; +}; + +// --------------------------------------------------------------------------- +// Participant lifecycle functions +// --------------------------------------------------------------------------- + +// Creates a fully initialized participant: builds descriptor, creates instance, +// joins SFU, sets join response, unmutes. Returns nullptr on failure. +std::unique_ptr createParticipant( + int id, + bool isReference, + GoInt sfuHandle, + std::shared_ptr threads, + bool quiet, + bool video, + std::vector>* allStates +); + +// Clean teardown: GoSfu_Leave, stop video, stop instance, reset. +void stopParticipant(ParticipantState* state, GoInt sfuHandle); + +// Validates group state: connection, audio, video. Returns result struct. +GroupValidationResult validateGroupState( + const std::vector>& states, + bool video +); + +// Prints a group call summary to stdout. Returns the success boolean. +bool printGroupSummary( + int customParticipants, + int referenceParticipants, + int duration, + bool video, + const GroupValidationResult& result, + bool anyFailed +); diff --git a/tools/tgcalls_cli/main.cpp b/tools/tgcalls_cli/main.cpp new file mode 100644 index 0000000000..7ed581a8d8 --- /dev/null +++ b/tools/tgcalls_cli/main.cpp @@ -0,0 +1,602 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "group_mode.h" +#include "group_churn_mode.h" +#include "Instance.h" +#include "FakeAudioDeviceModule.h" +#include "VideoCaptureInterface.h" +#include "v2/InstanceV2Impl.h" +#include "v2/InstanceV2CompatImpl.h" +#include "v2/InstanceV2ReferenceImpl.h" + +#include "modules/audio_device/include/audio_device.h" +#include "api/task_queue/task_queue_factory.h" + +// Stub: AudioDeviceModule::Create is referenced by InstanceV2Impl as a fallback +// but never called when createAudioDeviceModule is provided in the Descriptor. +namespace webrtc { +rtc::scoped_refptr AudioDeviceModule::Create( + AudioDeviceModule::AudioLayer audio_layer, + TaskQueueFactory* task_queue_factory) { + return nullptr; +} +} // namespace webrtc + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static auto gStartTime = std::chrono::steady_clock::now(); + +static double elapsed() { + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration(now - gStartTime).count(); +} + +static bool gQuiet = false; + +static void logMsg(const char* role, const char* fmt, ...) { + if (gQuiet) return; + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + fprintf(stderr, "[%7.3f] %s: %s\n", elapsed(), role, buf); +} + +static const char* stateName(tgcalls::State s) { + switch (s) { + case tgcalls::State::WaitInit: return "WaitInit"; + case tgcalls::State::WaitInitAck: return "WaitInitAck"; + case tgcalls::State::Established: return "Established"; + case tgcalls::State::Failed: return "Failed"; + case tgcalls::State::Reconnecting:return "Reconnecting"; + } + return "Unknown"; +} + +static std::string hexEncode(const std::array& data) { + char buf[33]; + for (size_t i = 0; i < 16; ++i) { + snprintf(buf + i * 2, 3, "%02x", data[i]); + } + return std::string(buf, 32); +} + +static tgcalls::RtcServer makeReflectorServer(const std::string& host, uint16_t port, + const std::array& peerTag) { + tgcalls::RtcServer server; + server.id = 1; + server.host = host; + server.port = port; + server.login = "reflector"; + server.password = hexEncode(peerTag); + server.isTurn = true; + server.isTcp = false; + return server; +} + +// --------------------------------------------------------------------------- +// SineRecorder - generates 440 Hz sine tone +// --------------------------------------------------------------------------- + +class SineRecorder : public tgcalls::FakeAudioDeviceModule::Recorder { +public: + SineRecorder() { + buffer_.resize(kFrameSamples * kChannels); + } + + tgcalls::AudioFrame Record() override { + for (size_t i = 0; i < kFrameSamples; ++i) { + double t = static_cast(phase_) / kSampleRate; + int16_t sample = static_cast(kAmplitude * std::sin(2.0 * M_PI * kFrequency * t)); + for (size_t ch = 0; ch < kChannels; ++ch) { + buffer_[i * kChannels + ch] = sample; + } + ++phase_; + } + + tgcalls::AudioFrame frame; + frame.audio_samples = buffer_.data(); + frame.num_samples = kFrameSamples; + frame.bytes_per_sample = sizeof(int16_t); + frame.num_channels = kChannels; + frame.samples_per_sec = kSampleRate; + frame.elapsed_time_ms = 0; + frame.ntp_time_ms = 0; + return frame; + } + + int32_t WaitForUs() override { + return 10000; // 10ms + } + +private: + static constexpr size_t kSampleRate = 48000; + static constexpr size_t kChannels = 2; + static constexpr size_t kFrameSamples = 480; // 10ms at 48kHz + static constexpr double kFrequency = 440.0; + static constexpr double kAmplitude = 3000.0; + + std::vector buffer_; + uint64_t phase_ = 0; +}; + +// --------------------------------------------------------------------------- +// NoOpRenderer - discards received audio (validation is done via BWE stats) +// --------------------------------------------------------------------------- + +class NoOpRenderer : public tgcalls::FakeAudioDeviceModule::Renderer { +public: + bool Render(const tgcalls::AudioFrame&) override { return true; } +}; + +// --------------------------------------------------------------------------- +// SignalingBridge +// --------------------------------------------------------------------------- + +struct SignalingBridge { + std::mutex mutex; + std::shared_ptr caller; + std::shared_ptr callee; + + // Network simulation + double dropRate = 0.0; + int delayMinMs = 0; + int delayMaxMs = 0; + std::mt19937 rng{std::random_device{}()}; + + void deliver(const char* fromRole, const std::vector& data, + std::shared_ptr& target) { + if (dropRate > 0.0) { + std::uniform_real_distribution dropDist(0.0, 1.0); + if (dropDist(rng) < dropRate) { + logMsg(fromRole, "signaling DROPPED (%zu bytes)", data.size()); + return; + } + } + if (delayMaxMs > 0) { + std::uniform_int_distribution delayDist(delayMinMs, delayMaxMs); + int delayMs = delayDist(rng); + if (delayMs > 0) { + logMsg(fromRole, "signaling delayed %dms (%zu bytes)", delayMs, data.size()); + auto dataCopy = data; + auto targetWeak = std::weak_ptr(target); + std::thread([dataCopy, targetWeak, delayMs]() { + std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); + if (auto t = targetWeak.lock()) { + t->receiveSignalingData(dataCopy); + } + }).detach(); + return; + } + } + if (target) { + target->receiveSignalingData(data); + } + } +}; + +// --------------------------------------------------------------------------- +// CallState +// --------------------------------------------------------------------------- + +struct CallState { + std::mutex mutex; + tgcalls::State callerState = tgcalls::State::WaitInit; + tgcalls::State calleeState = tgcalls::State::WaitInit; + double establishedAt = -1.0; + std::vector errors; +}; + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +int main(int argc, char* argv[]) { + int duration = 10; + std::string mode; + std::string reflectorAddr; + std::string reflectorList; + std::string version = "13.0.0"; + std::string version2; + double dropRate = 0.0; + int delayMinMs = 0; + int delayMaxMs = 0; + int participants = 3; + int referenceParticipants = 0; + bool enableVideo = false; + int churnCycles = 100; + std::string networkScenario; + + for (int i = 1; i < argc; ++i) { + if (std::string(argv[i]) == "--duration" && i + 1 < argc) { + duration = std::atoi(argv[++i]); + } else if (std::string(argv[i]) == "--quiet") { + gQuiet = true; + } else if (std::string(argv[i]) == "--mode" && i + 1 < argc) { + mode = argv[++i]; + } else if (std::string(argv[i]) == "--reflector" && i + 1 < argc) { + reflectorAddr = argv[++i]; + } else if (std::string(argv[i]) == "--reflector-list" && i + 1 < argc) { + reflectorList = argv[++i]; + } else if (std::string(argv[i]) == "--drop-rate" && i + 1 < argc) { + dropRate = std::atof(argv[++i]); + } else if (std::string(argv[i]) == "--version" && i + 1 < argc) { + version = argv[++i]; + } else if (std::string(argv[i]) == "--version2" && i + 1 < argc) { + version2 = argv[++i]; + } else if (std::string(argv[i]) == "--participants" && i + 1 < argc) { + participants = std::atoi(argv[++i]); + } else if (std::string(argv[i]) == "--reference-participants" && i + 1 < argc) { + referenceParticipants = std::atoi(argv[++i]); + } else if (std::string(argv[i]) == "--video") { + enableVideo = true; + } else if (std::string(argv[i]) == "--churn-cycles" && i + 1 < argc) { + churnCycles = std::atoi(argv[++i]); + } else if (std::string(argv[i]) == "--network-scenario" && i + 1 < argc) { + networkScenario = argv[++i]; + } else if (std::string(argv[i]) == "--delay" && i + 1 < argc) { + std::string delayStr = argv[++i]; + auto dashPos = delayStr.find('-'); + if (dashPos != std::string::npos) { + delayMinMs = std::atoi(delayStr.substr(0, dashPos).c_str()); + delayMaxMs = std::atoi(delayStr.substr(dashPos + 1).c_str()); + } else { + delayMinMs = 0; + delayMaxMs = std::atoi(delayStr.c_str()); + } + } + } + + if (version2.empty()) { + version2 = version; + } + + // If --reflector-list provided, pick one at random + if (!reflectorList.empty()) { + std::vector addrs; + size_t pos = 0; + while (pos < reflectorList.size()) { + size_t next = reflectorList.find(',', pos); + if (next == std::string::npos) next = reflectorList.size(); + std::string addr = reflectorList.substr(pos, next - pos); + if (!addr.empty()) addrs.push_back(addr); + pos = next + 1; + } + if (addrs.empty()) { + fprintf(stderr, "Error: --reflector-list is empty\n"); + return 1; + } + std::random_device rd; + std::mt19937 rng(rd()); + std::uniform_int_distribution dist(0, addrs.size() - 1); + reflectorAddr = addrs[dist(rng)]; + if (reflectorAddr.rfind(':') == std::string::npos) { + std::uniform_int_distribution portDist(596, 599); + reflectorAddr += ":" + std::to_string(portDist(rng)); + } + if (mode.empty()) mode = "reflector"; + } + + // Validate --mode + if (mode.empty()) { + fprintf(stderr, "Error: --mode is required (p2p, reflector, group, or group-churn)\n"); + return 1; + } + if (mode != "p2p" && mode != "reflector" && mode != "group" && mode != "group-churn") { + fprintf(stderr, "Error: --mode must be 'p2p', 'reflector', 'group', or 'group-churn'\n"); + return 1; + } + + // Group mode: dispatch to separate implementation + if (mode == "group") { + return runGroupMode(participants, referenceParticipants, duration, gQuiet, enableVideo, networkScenario); + } + if (mode == "group-churn") { + return runGroupChurnMode(participants, referenceParticipants, duration, gQuiet, enableVideo, churnCycles); + } + if (mode == "reflector" && reflectorAddr.empty()) { + fprintf(stderr, "Error: --reflector host:port is required with --mode reflector\n"); + return 1; + } + if (mode == "p2p" && !reflectorAddr.empty()) { + fprintf(stderr, "Error: --reflector cannot be used with --mode p2p\n"); + return 1; + } + + // Parse reflector address + std::string reflectorHost; + uint16_t reflectorPort = 0; + if (mode == "reflector") { + auto colonPos = reflectorAddr.rfind(':'); + if (colonPos == std::string::npos) { + fprintf(stderr, "Error: --reflector must be in host:port format\n"); + return 1; + } + reflectorHost = reflectorAddr.substr(0, colonPos); + reflectorPort = static_cast(std::atoi(reflectorAddr.substr(colonPos + 1).c_str())); + if (reflectorPort == 0) { + fprintf(stderr, "Error: invalid reflector port\n"); + return 1; + } + } + + // Generate peer tags for reflector mode + std::array callerPeerTag{}; + std::array calleePeerTag{}; + if (mode == "reflector") { + std::random_device rd; + std::mt19937 rng(rd()); + std::uniform_int_distribution dist(0, 255); + for (auto& b : callerPeerTag) { + b = static_cast(dist(rng)); + } + calleePeerTag = callerPeerTag; + callerPeerTag[0] = 0x00; + calleePeerTag[0] = 0x01; + } + + // Register implementations + tgcalls::Register(); + tgcalls::Register(); + tgcalls::Register(); + + // Create shared encryption key + auto keyData = std::make_shared>(); + { + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, 255); + for (auto& b : *keyData) { + b = static_cast(dist(rng)); + } + } + + // Bridge and state + auto bridge = std::make_shared(); + bridge->dropRate = dropRate; + bridge->delayMinMs = delayMinMs; + bridge->delayMaxMs = delayMaxMs; + auto callState = std::make_shared(); + + // Audio components + auto callerRecorder = std::make_shared(); + auto callerRenderer = std::make_shared(); + auto calleeRecorder = std::make_shared(); + auto calleeRenderer = std::make_shared(); + + // Stats log paths (per-process to avoid collisions in parallel runs) + std::string callerStatsPath = "/tmp/tgcalls_cli_caller_" + std::to_string(getpid()) + ".json"; + std::string calleeStatsPath = "/tmp/tgcalls_cli_callee_" + std::to_string(getpid()) + ".json"; + + // --- Caller descriptor --- + auto callerDesc = (tgcalls::Descriptor){ + .version = version, + .config = { + .initializationTimeout = 10.0, + .receiveTimeout = 10.0, + .enableP2P = (mode == "p2p"), + .statsLogPath = {callerStatsPath}, + }, + .rtcServers = (mode == "reflector") + ? std::vector{makeReflectorServer(reflectorHost, reflectorPort, callerPeerTag)} + : std::vector{}, + .encryptionKey = tgcalls::EncryptionKey(keyData, true), + .stateUpdated = [callState](tgcalls::State state) { + logMsg("Caller", "state -> %s", stateName(state)); + std::lock_guard lock(callState->mutex); + callState->callerState = state; + if (state == tgcalls::State::Established && callState->establishedAt < 0) { + callState->establishedAt = elapsed(); + } + if (state == tgcalls::State::Failed) { + callState->errors.push_back("Caller entered Failed state"); + } + }, + .signalingDataEmitted = [bridge](const std::vector& data) { + logMsg("Caller", "signaling data emitted (%zu bytes)", data.size()); + std::lock_guard lock(bridge->mutex); + bridge->deliver("Caller", data, bridge->callee); + }, + .createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( + callerRenderer, callerRecorder, + tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} + ), + }; + + // --- Callee descriptor --- + auto calleeDesc = (tgcalls::Descriptor){ + .version = version2, + .config = { + .initializationTimeout = 10.0, + .receiveTimeout = 10.0, + .enableP2P = (mode == "p2p"), + .statsLogPath = {calleeStatsPath}, + }, + .rtcServers = (mode == "reflector") + ? std::vector{makeReflectorServer(reflectorHost, reflectorPort, calleePeerTag)} + : std::vector{}, + .encryptionKey = tgcalls::EncryptionKey(keyData, false), + .stateUpdated = [callState](tgcalls::State state) { + logMsg("Callee", "state -> %s", stateName(state)); + std::lock_guard lock(callState->mutex); + callState->calleeState = state; + if (state == tgcalls::State::Established && callState->establishedAt < 0) { + callState->establishedAt = elapsed(); + } + if (state == tgcalls::State::Failed) { + callState->errors.push_back("Callee entered Failed state"); + } + }, + .signalingDataEmitted = [bridge](const std::vector& data) { + logMsg("Callee", "signaling data emitted (%zu bytes)", data.size()); + std::lock_guard lock(bridge->mutex); + bridge->deliver("Callee", data, bridge->caller); + }, + .createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( + calleeRenderer, calleeRecorder, + tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} + ), + }; + + // Create instances + auto callerInstance = std::shared_ptr( + tgcalls::Meta::Create(version, std::move(callerDesc)).release()); + if (!callerInstance) { + fprintf(stderr, "Error: unknown version '%s'\n", version.c_str()); + return 1; + } + logMsg("Caller", "created (version %s)", version.c_str()); + + auto calleeInstance = std::shared_ptr( + tgcalls::Meta::Create(version2, std::move(calleeDesc)).release()); + if (!calleeInstance) { + fprintf(stderr, "Error: unknown callee version '%s'\n", version2.c_str()); + return 1; + } + logMsg("Callee", "created (version %s)", version2.c_str()); + + // Wire bridge + { + std::lock_guard lock(bridge->mutex); + bridge->caller = callerInstance; + bridge->callee = calleeInstance; + } + + logMsg("Main", "sleeping for %d seconds...", duration); + std::this_thread::sleep_for(std::chrono::seconds(duration)); + + // Stop both instances + logMsg("Main", "stopping instances..."); + + std::atomic stopCount{0}; + std::mutex stopMutex; + std::condition_variable stopCv; + + auto onStopped = [&](const char* role) { + return [&, role](tgcalls::FinalState) { + logMsg(role, "stopped"); + stopCount.fetch_add(1); + std::lock_guard lock(stopMutex); + stopCv.notify_all(); + }; + }; + + callerInstance->stop(onStopped("Caller")); + calleeInstance->stop(onStopped("Callee")); + + // Wait for both stop callbacks (up to 5 seconds) + { + std::unique_lock lock(stopMutex); + stopCv.wait_for(lock, std::chrono::seconds(5), [&] { + return stopCount.load() >= 2; + }); + } + + // Release instances — clear bridge first to prevent signaling during teardown + { + std::lock_guard lock(bridge->mutex); + bridge->caller.reset(); + bridge->callee.reset(); + } + callerInstance.reset(); + calleeInstance.reset(); + + // Read stats logs: count bitrate records and check for non-zero BWE + struct StatsResult { + int bitrateRecords = 0; + bool hasNonZeroBwe = false; + }; + auto parseStatsLog = [](const std::string& path) -> StatsResult { + StatsResult result; + std::ifstream f(path); + if (!f.is_open()) return result; + std::string content((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + size_t pos = 0; + while ((pos = content.find("\"b\":", pos)) != std::string::npos) { + pos += 4; + result.bitrateRecords++; + // Parse the integer value after "b": + int val = std::atoi(content.c_str() + pos); + if (val > 0) { + result.hasNonZeroBwe = true; + } + } + return result; + }; + + auto callerStats = parseStatsLog(callerStatsPath); + auto calleeStats = parseStatsLog(calleeStatsPath); + unlink(callerStatsPath.c_str()); + unlink(calleeStatsPath.c_str()); + + // Print summary + { + std::lock_guard lock(callState->mutex); + + bool established = (callState->establishedAt >= 0); + + printf("\n=== Call Summary ===\n"); + printf("Duration: %ds\n", duration); + if (dropRate > 0.0 || delayMaxMs > 0) { + printf("Signaling: drop=%.0f%% delay=%d-%dms\n", + dropRate * 100.0, delayMinMs, delayMaxMs); + } + if (mode == "reflector") { + printf("Mode: reflector (%s:%d)\n", reflectorHost.c_str(), reflectorPort); + } else { + printf("Mode: p2p\n"); + } + printf("Caller state: %s\n", stateName(callState->callerState)); + printf("Callee state: %s\n", stateName(callState->calleeState)); + if (callState->establishedAt >= 0) { + printf("Call established: yes (at %.3fs)\n", callState->establishedAt); + } else { + printf("Call established: no\n"); + } + bool bweNonZero = callerStats.hasNonZeroBwe && calleeStats.hasNonZeroBwe; + + printf("Stats log: caller=%d callee=%d bitrate records\n", + callerStats.bitrateRecords, calleeStats.bitrateRecords); + printf("BWE non-zero: %s\n", bweNonZero ? "yes" : "no"); + + bool statsCollected = (callerStats.bitrateRecords > 0 && calleeStats.bitrateRecords > 0); + + if (callState->errors.empty()) { + printf("Errors: none\n"); + } else { + printf("Errors:\n"); + for (const auto& err : callState->errors) { + printf(" - %s\n", err.c_str()); + } + } + + // Use _exit() to skip static destruction. ThreadLocalObject's destructor + // posts fire-and-forget cleanup tasks to the tgcalls media thread. If we + // return normally, static destruction tears down the StaticThreads thread + // pool while those tasks may still be executing, causing "pure virtual + // function called" when a half-destroyed object's vtable is accessed. + fflush(stdout); + fflush(stderr); + _exit(established && statsCollected && bweNonZero ? 0 : 1); + } +} diff --git a/tools/tgcalls_cli/run-local-test.sh b/tools/tgcalls_cli/run-local-test.sh new file mode 100755 index 0000000000..29fc072766 --- /dev/null +++ b/tools/tgcalls_cli/run-local-test.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Run N parallel P2P tests locally and report aggregate results. +# +# Usage: +# ./run-local-test.sh # 100 calls, 15s each, 30% loss +# ./run-local-test.sh -n 1000 # 1000 calls +# ./run-local-test.sh -n 500 -j 200 # 500 calls, 200 parallel +# ./run-local-test.sh -n 100 -d 30 # 100 calls, 30s each +# ./run-local-test.sh --drop-rate 0.5 # 50% loss + +set -euo pipefail + +BINARY="./bazel-bin/tools/tgcalls_cli/tgcalls_cli" +NUM=100 +PARALLEL=150 +DURATION=15 +DROP_RATE=0.3 +DELAY="50-200" +MODE="p2p" +VERSION="13.0.0" + +while [[ $# -gt 0 ]]; do + case $1 in + -n) NUM="$2"; shift 2 ;; + -j) PARALLEL="$2"; shift 2 ;; + -d) DURATION="$2"; shift 2 ;; + --drop-rate) DROP_RATE="$2"; shift 2 ;; + --delay) DELAY="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + *) echo "Usage: $0 [-n NUM] [-j PARALLEL] [-d DURATION] [--drop-rate RATE] [--delay MIN-MAX] [--mode MODE] [--version VER]"; exit 1 ;; + esac +done + +if [ ! -x "$BINARY" ]; then + echo "Binary not found: $BINARY" + echo "Run: ./build-input/bazel-8.4.2 build //tools/tgcalls_cli:tgcalls_cli" + exit 1 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +echo "Running $NUM calls ($PARALLEL parallel, ${DURATION}s each, drop=${DROP_RATE}, delay=${DELAY}ms, mode=${MODE}, version=${VERSION})" + +START=$(date +%s) +launched=0 +wave=0 + +while [ $launched -lt $NUM ]; do + wave=$((wave + 1)) + remaining=$((NUM - launched)) + batch=$((remaining > PARALLEL ? PARALLEL : remaining)) + + pids=() + for i in $(seq 1 $batch); do + id=$((launched + i)) + ( + if "$BINARY" --mode "$MODE" --duration "$DURATION" \ + --drop-rate "$DROP_RATE" --delay "$DELAY" --version "$VERSION" --quiet \ + > /dev/null 2>&1; then + echo "pass" > "$TMPDIR/$id" + else + echo "fail" > "$TMPDIR/$id" + fi + ) & + pids+=($!) + done + + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done + + launched=$((launched + batch)) + echo " Wave $wave: $launched/$NUM done" +done + +END=$(date +%s) +ELAPSED=$((END - START)) + +# Tally +success=0 +failed=0 +for f in "$TMPDIR"/*; do + [ -f "$f" ] || continue + if [ "$(cat "$f")" = "pass" ]; then + success=$((success + 1)) + else + failed=$((failed + 1)) + fi +done + +echo "" +echo "=== Local Mass Test Results ===" +echo "Total: $NUM" +echo "Success: $success" +echo "Failed: $failed" +if [ $NUM -gt 0 ]; then + rate=$(echo "scale=1; $success * 100 / $NUM" | bc) + echo "Rate: ${rate}%" +fi +echo "Duration: ${ELAPSED}s" +echo "Parallel: $PARALLEL" + +exit 0 diff --git a/tools/tgcalls_cli/run-test.sh b/tools/tgcalls_cli/run-test.sh new file mode 100755 index 0000000000..cf75776ade --- /dev/null +++ b/tools/tgcalls_cli/run-test.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Launch N tgcalls test tasks on ECS Fargate, spread across reflectors. +# +# Usage: +# ./run-test.sh # 10 tasks, 30s each +# ./run-test.sh -n 100 # 100 tasks +# ./run-test.sh -n 50 -d 60 # 50 tasks, 60s each +# ./run-test.sh --results # fetch results from last run + +set -euo pipefail + +CLUSTER="tgcalls-test" +TASK_DEF="tgcalls-test" +REGION="eu-west-1" +SUBNETS="subnet-0292f49f3b4885428,subnet-09b8edab6eb20b837,subnet-0f464b5c62c9a6d1a" +SECURITY_GROUP="sg-0d87a1f19be76c160" +LOG_GROUP="/ecs/tgcalls-test" +REFLECTOR_URL="https://core.telegram.org/getReflectorList" +RUN_FILE="/tmp/tgcalls-last-run.txt" +STATUS_FILE="/tmp/tgcalls-last-status.txt" + +NUM_TASKS=10 +DURATION=30 + +usage() { + echo "Usage: $0 [-n NUM_TASKS] [-d DURATION_SECS] [--results]" + exit 1 +} + +fetch_results() { + if [ ! -f "$RUN_FILE" ]; then + echo "No run file found. Run a test first." + exit 1 + fi + + echo "Fetching results from last run..." + echo "" + + TMPDIR_RESULTS=$(mktemp -d) + RESULTS_PARALLEL=20 + total=$(wc -l < "$RUN_FILE" | tr -d ' ') + fetched=0 + + # Fetch logs in parallel + while IFS= read -r task_id; do + stream="tgcalls/tgcalls/${task_id}" + (aws logs get-log-events \ + --log-group-name "$LOG_GROUP" \ + --log-stream-name "$stream" \ + --region "$REGION" \ + --query 'events[*].message' \ + --output text > "${TMPDIR_RESULTS}/${task_id}" 2>/dev/null || true) & + + fetched=$((fetched + 1)) + # Throttle: wait every RESULTS_PARALLEL calls + if [ $((fetched % RESULTS_PARALLEL)) -eq 0 ]; then + wait + echo -ne " Fetched $fetched/$total\r" + fi + done < "$RUN_FILE" + wait + echo " Fetched $total/$total" + echo "" + + # Tally results + success=0 + fail=0 + errors="" + no_logs_tasks=() + + for result_file in "${TMPDIR_RESULTS}"/*; do + [ -f "$result_file" ] || continue + task_id=$(basename "$result_file") + output=$(cat "$result_file") + + if [ -z "$output" ]; then + # No logs yet — queue for retry + no_logs_tasks+=("$task_id") + elif echo "$output" | tr '\t' '\n' | grep -q "Audio received:.*yes" && echo "$output" | tr '\t' '\n' | grep -q "Call established:.*yes"; then + success=$((success + 1)) + else + fail=$((fail + 1)) + reflector=$(echo "$output" | tr '\t' '\n' | grep -o 'reflector ([^)]*' | sed 's/reflector (//' || echo "unknown") + errors="${errors}\n ${task_id}: reflector=${reflector}" + fi + done + + rm -rf "$TMPDIR_RESULTS" + + # Retry tasks that had no logs + if [ ${#no_logs_tasks[@]} -gt 0 ]; then + echo "Retrying ${#no_logs_tasks[@]} tasks with missing logs..." + sleep 5 + for task_id in "${no_logs_tasks[@]}"; do + stream="tgcalls/tgcalls/${task_id}" + output=$(aws logs get-log-events \ + --log-group-name "$LOG_GROUP" \ + --log-stream-name "$stream" \ + --region "$REGION" \ + --query 'events[*].message' \ + --output text 2>/dev/null || true) + + if [ -n "$output" ] && echo "$output" | tr '\t' '\n' | grep -q "Audio received:.*yes" && echo "$output" | tr '\t' '\n' | grep -q "Call established:.*yes"; then + success=$((success + 1)) + else + fail=$((fail + 1)) + ecs_info="" + if [ -f "$STATUS_FILE" ]; then + ecs_info=$(grep "^${task_id}" "$STATUS_FILE" | head -1 | cut -f2-3) + fi + if [ -n "$ecs_info" ]; then + errors="${errors}\n ${task_id}: exit=${ecs_info}" + else + errors="${errors}\n ${task_id} (no logs, no ECS status)" + fi + fi + done + echo "" + fi + + echo "=== Test Results ===" + echo "Total tasks: $total" + echo "Success: $success" + echo "Failed: $fail" + if [ -n "$errors" ]; then + echo -e "\nFailed tasks:${errors}" + fi + exit 0 +} + +# Parse args +while [[ $# -gt 0 ]]; do + case $1 in + -n) NUM_TASKS="$2"; shift 2 ;; + -d) DURATION="$2"; shift 2 ;; + --results) fetch_results ;; + *) usage ;; + esac +done + +# Fetch reflector list — IPs only (port randomized by CLI) +echo "Fetching reflector list..." +REFLECTOR_CSV=$(curl -s "$REFLECTOR_URL" | cut -d: -f1 | sort -u | tr '\n' ',' | sed 's/,$//') +NUM_REFLECTORS=$(echo "$REFLECTOR_CSV" | tr ',' '\n' | wc -l | tr -d ' ') +echo "Got $NUM_REFLECTORS unique reflector IPs" + +# To inject bad addresses for testing, uncomment: +# NUM_BAD=$(( NUM_REFLECTORS / 9 )) +# BAD_CSV=$(for i in $(seq 1 $NUM_BAD); do echo -n "10.255.255.$((i % 256)):1,"; done | sed 's/,$//') +# REFLECTOR_CSV="${REFLECTOR_CSV},${BAD_CSV}" +# echo "Injected $NUM_BAD bad addresses (~10% of pool)" + +echo "Launching $NUM_TASKS tasks (${DURATION}s each), each picks a random reflector..." +echo "" + +# Clear run files +> "$RUN_FILE" +> "$STATUS_FILE" + +# Launch in waves of WAVE_SIZE, waiting for each wave to complete before the next. +# Within each wave, fire PARALLEL API calls concurrently (each launching up to 10 tasks). +WAVE_SIZE=500 +PARALLEL=10 +TMPDIR_LAUNCH=$(mktemp -d) +remaining=$NUM_TASKS +wave=0 + +while [ $remaining -gt 0 ]; do + wave=$((wave + 1)) + wave_target=$((remaining > WAVE_SIZE ? WAVE_SIZE : remaining)) + wave_arns=() + wave_launched=0 + + echo "=== Wave $wave: launching $wave_target tasks ===" + + while [ $wave_launched -lt $wave_target ]; do + pids=() + api_calls=0 + for p in $(seq 1 $PARALLEL); do + left=$((wave_target - wave_launched - api_calls * 10)) + [ $left -le 0 ] && break + batch=$((left > 10 ? 10 : left)) + outfile="${TMPDIR_LAUNCH}/batch_${wave}_${wave_launched}_${p}" + api_calls=$((api_calls + 1)) + + (aws ecs run-task --region "$REGION" \ + --cluster "$CLUSTER" \ + --task-definition "$TASK_DEF" \ + --launch-type FARGATE \ + --count "$batch" \ + --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SECURITY_GROUP}],assignPublicIp=ENABLED}" \ + --overrides "{\"containerOverrides\":[{\"name\":\"tgcalls\",\"command\":[\"--quiet\",\"--reflector-list\",\"${REFLECTOR_CSV}\",\"--duration\",\"${DURATION}\",\"--drop-rate\",\"0.3\",\"--delay\",\"50-200\"]}]}" \ + --query 'tasks[*].taskArn' --output text > "$outfile" 2>&1) & + pids+=($!) + done + + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done + + for outfile in "${TMPDIR_LAUNCH}"/batch_*; do + [ -f "$outfile" ] || continue + while read -r arn; do + if [[ "$arn" == arn:* ]]; then + task_id="${arn##*/}" + wave_arns+=("$arn") + echo "$task_id" >> "$RUN_FILE" + wave_launched=$((wave_launched + 1)) + fi + done < <(tr '\t' '\n' < "$outfile") + rm -f "$outfile" + done + + echo " Launched $wave_launched/$wave_target in wave $wave" + done + + remaining=$((remaining - wave_launched)) + echo " Waiting for wave $wave ($wave_launched tasks) to finish..." + + # Wait in batches of 100 + for ((start=0; start<${#wave_arns[@]}; start+=100)); do + batch=("${wave_arns[@]:$start:100}") + aws ecs wait tasks-stopped \ + --cluster "$CLUSTER" \ + --tasks "${batch[@]}" \ + --region "$REGION" 2>/dev/null || true + done + + # Collect ECS task status while data is fresh (expires after ~1hr) + echo " Collecting task status for wave $wave..." + for ((start=0; start<${#wave_arns[@]}; start+=100)); do + batch=("${wave_arns[@]:$start:100}") + aws ecs describe-tasks --cluster "$CLUSTER" --tasks "${batch[@]}" --region "$REGION" \ + --query 'tasks[*].[containers[0].taskArn,containers[0].exitCode,stoppedReason]' \ + --output text 2>/dev/null | while IFS=$'\t' read -r arn exit_code reason; do + task_id="${arn##*/}" + echo -e "${task_id}\t${exit_code}\t${reason}" >> "$STATUS_FILE" + done + done + + echo " Wave $wave complete." + echo "" +done + +rm -rf "$TMPDIR_LAUNCH" +total_launched=$(wc -l < "$RUN_FILE" | tr -d ' ') + +echo "Launched $total_launched/$NUM_TASKS total tasks." +echo "Run '$0 --results' to see results." diff --git a/tools/tgcalls_cli/run-until-crash.sh b/tools/tgcalls_cli/run-until-crash.sh new file mode 100755 index 0000000000..0bf14ce74c --- /dev/null +++ b/tools/tgcalls_cli/run-until-crash.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Run parallel tests, stop on first crash (non-zero exit). +set -euo pipefail + +BINARY="./bazel-bin/tools/tgcalls_cli/tgcalls_cli" +PARALLEL=250 +DURATION=15 +VERSION="11.0.0" +DROP_RATE=0.3 +DELAY="50-200" +MODE="p2p" + +while [[ $# -gt 0 ]]; do + case $1 in + -j) PARALLEL="$2"; shift 2 ;; + -d) DURATION="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --drop-rate) DROP_RATE="$2"; shift 2 ;; + --delay) DELAY="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + *) echo "Usage: $0 [-j PARALLEL] [-d DURATION] [--version VER] [--drop-rate R] [--delay D] [--mode M]"; exit 1 ;; + esac +done + +if [ ! -x "$BINARY" ]; then + echo "Binary not found: $BINARY" + exit 1 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +echo "Running waves of $PARALLEL until first crash (${DURATION}s, drop=${DROP_RATE}, delay=${DELAY}, version=${VERSION})" + +wave=0 +total=0 +while true; do + wave=$((wave + 1)) + pids=() + for i in $(seq 1 $PARALLEL); do + id=$((total + i)) + ( + set +e + "$BINARY" --mode "$MODE" --duration "$DURATION" \ + --drop-rate "$DROP_RATE" --delay "$DELAY" --version "$VERSION" --quiet \ + > "$TMPDIR/${id}.out" 2>"$TMPDIR/${id}.err" + echo $? > "$TMPDIR/${id}.rc" + ) & + pids+=($!) + done + + for pid in "${pids[@]}"; do + wait "$pid" 2>/dev/null || true + done + + total=$((total + PARALLEL)) + + # Check for crashes + crashes=0 + for i in $(seq $((total - PARALLEL + 1)) $total); do + rc_file="$TMPDIR/${i}.rc" + if [ ! -f "$rc_file" ]; then + crashes=$((crashes + 1)) + echo "" + echo "=== CRASH in run $i (no rc file) ===" + echo "--- stderr ---" + cat "$TMPDIR/${i}.err" 2>/dev/null || echo "(empty)" + else + rc=$(cat "$rc_file") + if [ "$rc" -gt 128 ] 2>/dev/null; then + crashes=$((crashes + 1)) + echo "" + echo "=== CRASH in run $i (exit $rc) ===" + echo "--- stderr ---" + cat "$TMPDIR/${i}.err" 2>/dev/null || echo "(empty)" + echo "--- stdout ---" + cat "$TMPDIR/${i}.out" 2>/dev/null || echo "(empty)" + # Only show first crash in detail + if [ $crashes -eq 1 ]; then + echo "=== END CRASH ===" + fi + fi + fi + done + + if [ $crashes -gt 0 ]; then + echo "" + echo "Wave $wave: $crashes crashes in $PARALLEL runs (total $total runs)" + exit 1 + fi + + echo " Wave $wave: $PARALLEL/$PARALLEL passed (total $total)" +done From 203727d4fb1825cea231a6155f3e2eed9e12b745 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Thu, 30 Apr 2026 22:13:55 +0200 Subject: [PATCH 49/69] Extract tgcalls helpers into tgcalls --- CLAUDE.md | 204 ---- Dockerfile | 52 - MODULE.bazel | 2 +- bazel-tgcalls-telegram | 1 - submodules/TgVoipWebrtc/tgcalls | 2 +- tools/go_sfu/BUILD | 18 - tools/go_sfu/CLAUDE.md | 107 -- tools/go_sfu/bandwidth.go | 475 --------- tools/go_sfu/bandwidth_test.go | 249 ----- tools/go_sfu/go.mod | 27 - tools/go_sfu/go.sum | 44 - tools/go_sfu/mux.go | 239 ----- tools/go_sfu/network_sim.go | 128 --- tools/go_sfu/participant.go | 637 ------------ tools/go_sfu/sfu.go | 1240 ----------------------- tools/go_sfu/twcc.go | 262 ----- tools/tgcalls_cli/BUILD | 37 - tools/tgcalls_cli/CLAUDE.md | 41 - tools/tgcalls_cli/fake_video_sink.h | 24 - tools/tgcalls_cli/fake_video_source.cpp | 149 --- tools/tgcalls_cli/fake_video_source.h | 38 - tools/tgcalls_cli/group_churn_mode.cpp | 205 ---- tools/tgcalls_cli/group_churn_mode.h | 10 - tools/tgcalls_cli/group_mode.cpp | 173 ---- tools/tgcalls_cli/group_mode.h | 5 - tools/tgcalls_cli/group_participant.cpp | 442 -------- tools/tgcalls_cli/group_participant.h | 143 --- tools/tgcalls_cli/main.cpp | 602 ----------- tools/tgcalls_cli/run-local-test.sh | 105 -- tools/tgcalls_cli/run-test.sh | 249 ----- tools/tgcalls_cli/run-until-crash.sh | 93 -- 31 files changed, 2 insertions(+), 6001 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 Dockerfile delete mode 120000 bazel-tgcalls-telegram delete mode 100644 tools/go_sfu/BUILD delete mode 100644 tools/go_sfu/CLAUDE.md delete mode 100644 tools/go_sfu/bandwidth.go delete mode 100644 tools/go_sfu/bandwidth_test.go delete mode 100644 tools/go_sfu/go.mod delete mode 100644 tools/go_sfu/go.sum delete mode 100644 tools/go_sfu/mux.go delete mode 100644 tools/go_sfu/network_sim.go delete mode 100644 tools/go_sfu/participant.go delete mode 100644 tools/go_sfu/sfu.go delete mode 100644 tools/go_sfu/twcc.go delete mode 100644 tools/tgcalls_cli/BUILD delete mode 100644 tools/tgcalls_cli/CLAUDE.md delete mode 100644 tools/tgcalls_cli/fake_video_sink.h delete mode 100644 tools/tgcalls_cli/fake_video_source.cpp delete mode 100644 tools/tgcalls_cli/fake_video_source.h delete mode 100644 tools/tgcalls_cli/group_churn_mode.cpp delete mode 100644 tools/tgcalls_cli/group_churn_mode.h delete mode 100644 tools/tgcalls_cli/group_mode.cpp delete mode 100644 tools/tgcalls_cli/group_mode.h delete mode 100644 tools/tgcalls_cli/group_participant.cpp delete mode 100644 tools/tgcalls_cli/group_participant.h delete mode 100644 tools/tgcalls_cli/main.cpp delete mode 100755 tools/tgcalls_cli/run-local-test.sh delete mode 100755 tools/tgcalls_cli/run-test.sh delete mode 100755 tools/tgcalls_cli/run-until-crash.sh diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 0df8fa773e..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,204 +0,0 @@ -# CLAUDE.md - -This is a testbench repository for the tgcalls VoIP library (from Telegram). It contains the full Telegram iOS source tree as a build dependency, but the focus is on testing and debugging tgcalls. - -## Build - -Requires Bazel 8.4.2 (download to `build-input/` if not present): - -```bash -# One-time setup: create build configuration stub -mkdir -p build-input/configuration-repository/provisioning -# Then populate MODULE.bazel, BUILD, variables.bzl, provisioning/BUILD -# (see build-input/configuration-repository/ for existing stubs) - -# Build the CLI test tool -./build-input/bazel-8.4.2 build //tools/tgcalls_cli:tgcalls_cli -``` - -The system-installed Bazel (v9) is NOT compatible with this codebase. - -## Linux Build - -Prerequisites (Ubuntu/Debian): -```bash -apt install gcc g++ cmake meson ninja-build nasm make autoconf automake libtool pkg-config zlib1g-dev libbz2-dev -``` - -Download the Linux Bazel 8.4.2 binary to `build-input/`: -```bash -curl -fL "https://github.com/bazelbuild/bazel/releases/download/8.4.2/bazel-8.4.2-linux-arm64" -o build-input/bazel-8.4.2-linux -chmod +x build-input/bazel-8.4.2-linux -``` - -Build the CLI test tool: -```bash -./build-input/bazel-8.4.2-linux build //tools/tgcalls_cli:tgcalls_cli -``` - -The same Bazel 8.4.2 version is required. The build uses the system GCC toolchain and system-installed cmake/meson/ninja for third-party library compilation. - -## Docker Build - -Build a minimal Linux container image from macOS (or any Docker host): - -```bash -# Build (uses BuildKit cache — first build ~5 min, rebuilds seconds) -docker build -t tgcalls-test . - -# Run locally -docker run --rm tgcalls-test --mode p2p --duration 5 --quiet -docker run --rm tgcalls-test --mode reflector --reflector 91.108.13.2:598 --duration 10 --quiet - -# Push to ECR for AWS deployment -docker tag tgcalls-test 654654616143.dkr.ecr.eu-west-1.amazonaws.com/tgcalls-test:latest -docker push 654654616143.dkr.ecr.eu-west-1.amazonaws.com/tgcalls-test:latest -``` - -The Dockerfile uses a multi-stage build: full build environment in stage 1, minimal runtime image (~50MB) in stage 2. Bazel's build cache is preserved across `docker build` invocations via `--mount=type=cache`. The image is built for ARM64 (matches Apple Silicon and Fargate ARM). - -## Testing - -### Local Mass Testing - -Run large-scale P2P tests locally using `run-local-test.sh`. Launches N parallel processes, each running a single call, and aggregates results. - -```bash -# 1000 calls, 150 parallel, 30% loss (default settings) -./tools/tgcalls_cli/run-local-test.sh -n 1000 - -# Custom parallelism and duration -./tools/tgcalls_cli/run-local-test.sh -n 500 -j 100 -d 30 - -# Custom loss parameters -./tools/tgcalls_cli/run-local-test.sh -n 1000 --drop-rate 0.5 --delay 100-300 -``` - -Options: `-n NUM` (count), `-j PARALLEL` (default 150), `-d DURATION` (default 15s), `--drop-rate RATE` (default 0.3), `--delay MIN-MAX` (default 50-200), `--mode MODE` (default p2p), `--version VER` (default 13.0.0). - -Typical results: 100% success rate at 30% loss on Apple Silicon (16 cores). - -### AWS Mass Testing - -Run large-scale reflector tests on ECS Fargate (ARM64). Infrastructure is pre-configured in eu-west-1. Requires Docker push first. - -```bash -# Launch 1000 tasks across all Telegram reflectors, 30s each -./tools/tgcalls_cli/run-test.sh -n 1000 -d 30 - -# Collect results -./tools/tgcalls_cli/run-test.sh --results -``` - -The script fetches the reflector list from `https://core.telegram.org/getReflectorList`, embeds the IPs as a `--reflector-list` argument (each task picks a random IP + random port 596-599), and launches in waves of 500 (Fargate concurrent task limit). Results are collected from CloudWatch Logs with automatic retry for delayed log delivery. - -**AWS resources** (eu-west-1, account 654654616143): -- ECR: `tgcalls-test` -- ECS cluster: `tgcalls-test` -- Task definition: `tgcalls-test` (ARM64 Fargate, 0.25 vCPU, 512MB) -- CloudWatch log group: `/ecs/tgcalls-test` -- Subnets: `subnet-0292f49f3b4885428`, `subnet-09b8edab6eb20b837`, `subnet-0f464b5c62c9a6d1a` -- Security group: `sg-0d87a1f19be76c160` - -**Cost**: ~$0.01 per 100 tasks (~$0.10 per 1000-task run). - -## tgcalls CLI Test Tool - -Located at `tools/tgcalls_cli/`. Runs tgcalls instances in-process with emulated signaling and validates audio/media flow. - -```bash -# P2P mode (direct loopback, no network) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 10 - -# Reflector mode (routes through a real Telegram UDP reflector) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode reflector --reflector 91.108.13.2:596 --duration 10 - -# Random reflector from a list (picks one at random, randomizes port 596-599 if no port given) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --reflector-list "91.108.13.2,91.108.13.3,91.108.9.1" --duration 10 - -# Simulate lossy signaling (30% drop, 50-200ms random delay) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 30 --drop-rate 0.3 --delay 50-200 - -# Quiet mode (summary only, full tgcalls logs dumped on failure) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode p2p --duration 5 --quiet - -# Group mode (in-process SFU with N participants) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 3 --duration 10 - -# Mixed group mode (CustomImpl + ReferenceImpl participants) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --reference-participants 2 --duration 15 - -# Group mode with video (H264 simulcast, pattern generator) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 15 - -# Mixed group with video (both CustomImpl and ReferenceImpl send/receive video) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --reference-participants 2 --video --duration 15 - -# ReferenceImpl-only video -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 0 --reference-participants 3 --video --duration 15 - -# Group churn stress test (100 join/leave cycles, then validate base group) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 3 --duration 10 - -# Group churn with video -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 3 --video --churn-cycles 100 --duration 10 - -# Mixed implementations churn -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group-churn --participants 2 --reference-participants 1 --video --duration 10 -``` - -`--mode` is required (`p2p`, `reflector`, `group`, or `group-churn`) unless `--reflector-list` is used (implies reflector mode). Exit code 0 = success. Exit code 1 = failure. - -For p2p/reflector: success = call established, stats logs non-empty, BWE non-zero for both sides. -For group (audio): success = all N participants report `isConnected = true` AND all participants receive remote audio (non-zero SSRC with level > 0.05 via `audioLevelsUpdated`). Remote 440Hz sine tone typically arrives at ~0.126 level. -For group (video): audio criteria plus every participant receives ≥1 decoded video frame from every other participant via `FakeVideoSink` frame counting. -For group-churn: success = all churn cycles complete without crash/hang AND base group passes group validation (all connected, all receiving audio, and if video, all receiving video from all other base participants). - -### CLI Options -- `--mode p2p|reflector|group|group-churn` — call mode (required unless `--reflector-list` used) -- `--reflector host:port` — single reflector address -- `--reflector-list addr,addr,...` — comma-separated list, one picked at random -- `--version VER` — caller tgcalls protocol version (default: `13.0.0`) -- `--version2 VER` — callee tgcalls protocol version (default: same as `--version`). Enables cross-version interop testing. -- `--participants N` — number of CustomImpl participants in group mode (default: 3) -- `--reference-participants N` — number of ReferenceImpl (PeerConnection-based) participants in group mode (default: 0). Total = `--participants` + `--reference-participants`. -- `--duration N` — test duration in seconds (default: 10) -- `--drop-rate 0.0-1.0` — signaling packet drop probability -- `--delay min-max` — signaling delay range in ms (e.g., `50-200`) -- `--video` — enable H264 video with simulcast in group mode (both CustomImpl and ReferenceImpl participants) -- `--churn-cycles N` — number of join/leave cycles in group-churn mode (default: 100) -- `--network-scenario NAME` — network simulation test scenario (e.g., `step-down-up`). Group mode only. -- `--quiet` — summary output only - -### Modes -- **P2P**: Direct loopback, `enableP2P=true`, no servers configured -- **Reflector**: Routes through a Telegram UDP reflector, `enableP2P=false`, configures `RtcServer` with `login="reflector"` and random peer tags (16 bytes, byte 0 = `0x00` for caller, `0x01` for callee) -- **Group**: In-process SFU with N participants using `GroupInstanceCustomImpl` and/or `GroupInstanceReferenceImpl`. The SFU is implemented in Go using Pion's low-level ICE/DTLS/SRTP/SCTP APIs (not PeerConnection), linked into the same process via CGo c-archive. Each participant gets a full ICE + DTLS + SRTP + SCTP transport stack over localhost UDP. Audio RTP is selectively forwarded between all participants. With `--video`, H264 video with 3-layer simulcast is enabled. Mixed-implementation groups (CustomImpl + ReferenceImpl) are supported via `--reference-participants`. -- **Group Churn**: Stress test for participant join/leave dynamics. Creates a base group of N participants, then rapidly cycles an additional participant in and out `--churn-cycles` times (default 100). After churn, validates that the base group is healthy: all connected, all receiving audio, and if `--video` is enabled, all receiving video. Alternates between CustomImpl and ReferenceImpl for the cycling participant. The `--duration` controls the stabilization wait after churn completes. - -## Project Structure - -- `tools/tgcalls_cli/` — CLI test tool (main.cpp, group_mode.cpp, group_participant.h/.cpp, group_churn_mode.h/.cpp, fake_video_source.h/.cpp, fake_video_sink.h, run-test.sh, run-local-test.sh, BUILD) -- `tools/go_sfu/` — Go/Pion SFU library (sfu.go, participant.go, mux.go, go.mod/go.sum), built as c-archive via rules_go + Gazelle, linked into tgcalls_cli -- `submodules/TgVoipWebrtc/tgcalls/tgcalls/` — tgcalls library source -- `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/` — group call implementations (GroupInstanceCustomImpl, GroupInstanceReferenceImpl, GroupNetworkManager, GroupJoinPayloadInternal) -- `submodules/TgVoipWebrtc/tgcalls/tgcalls/v2/` — v2 implementation (InstanceV2Impl, InstanceV2ReferenceImpl, InstanceV2CompatImpl, NativeNetworkingImpl, SignalingSctpConnection, SignalingTranslator) -- `submodules/TgVoipWebrtc/BUILD` — contains `tgcalls_core` target (C++ only, macOS-native) and `TgVoipWebrtc` target (iOS, ObjC) -- `third-party/webrtc/` — WebRTC source and BUILD -- `third-party/webrtc/webrtc/net/dcsctp/` — dc-sctp (SCTP implementation) -- `third-party/webrtc/webrtc/media/sctp/dcsctp_transport.cc` — WebRTC SCTP wrapper -- `third-party/` — other dependencies (opus, libvpx, ffmpeg, boringssl, etc.) -- `docs/superpowers/specs/` — design specs -- `docs/superpowers/plans/` — implementation plans - -## Code Style -- **Naming**: PascalCase for types, camelCase for variables/methods -- **Language**: C++17 for tgcalls code -- **Formatting**: Standard C++ formatting - -## Further Context - -When working in these areas, additional `CLAUDE.md` files load automatically: -- `tools/tgcalls_cli/CLAUDE.md` — CLI test tool architecture (P2P/Reflector, Group), supported version matrix -- `tools/go_sfu/CLAUDE.md` — Go SFU internals: build integration, bandwidth adaptation, transport-cc feedback, network simulation -- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals: macOS/Linux build patches, SCTP signaling, InstanceV2CompatImpl, GroupInstanceCustomImpl/ReferenceImpl, video pitfalls, known issues diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ef8d208be5..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,52 +0,0 @@ -# syntax=docker/dockerfile:1 -# Multi-stage build for tgcalls_cli Linux container -# Build: docker build -t tgcalls-test . -# Run: docker run tgcalls-test --mode reflector --reflector 91.108.13.2:598 --duration 10 - -# ============================================================ -# Stage 1: Build -# ============================================================ -FROM ubuntu:24.04 AS builder - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc g++ cmake meson ninja-build nasm make \ - autoconf automake libtool pkg-config python3 \ - unzip curl ca-certificates patch \ - zlib1g-dev libbz2-dev \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src - -# Copy source tree -COPY . . - -# Always download Bazel for the container's architecture (host copy may be wrong arch) -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "x86_64" ]; then BAZEL_ARCH="x86_64"; \ - elif [ "$ARCH" = "aarch64" ]; then BAZEL_ARCH="arm64"; \ - else echo "Unsupported arch: $ARCH" && exit 1; fi && \ - curl -fL "https://github.com/bazelbuild/bazel/releases/download/8.4.2/bazel-8.4.2-linux-${BAZEL_ARCH}" \ - -o build-input/bazel-8.4.2-linux && \ - chmod +x build-input/bazel-8.4.2-linux - -# Build with persistent Bazel cache -RUN --mount=type=cache,target=/root/.cache/bazel \ - ./build-input/bazel-8.4.2-linux build //tools/tgcalls_cli:tgcalls_cli \ - --strategy=Genrule=standalone --spawn_strategy=standalone && \ - cp bazel-bin/tools/tgcalls_cli/tgcalls_cli /tmp/tgcalls_cli - -# ============================================================ -# Stage 2: Runtime (minimal) -# ============================================================ -FROM ubuntu:24.04 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /tmp/tgcalls_cli /usr/local/bin/tgcalls_cli - -ENTRYPOINT ["tgcalls_cli"] -CMD ["--help"] diff --git a/MODULE.bazel b/MODULE.bazel index a81f6bc28f..06eeac82fb 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -11,7 +11,7 @@ go_sdk.download(version = "1.24.2") bazel_dep(name = "gazelle", version = "0.43.0", repo_name = "bazel_gazelle") go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps") -go_deps.from_file(go_mod = "//tools/go_sfu:go.mod") +go_deps.from_file(go_mod = "//submodules/TgVoipWebrtc/tgcalls/tools/go_sfu:go.mod") use_repo( go_deps, "com_github_pion_datachannel", diff --git a/bazel-tgcalls-telegram b/bazel-tgcalls-telegram deleted file mode 120000 index 527907b756..0000000000 --- a/bazel-tgcalls-telegram +++ /dev/null @@ -1 +0,0 @@ -/private/var/tmp/_bazel_ali/c2a220fda8d2ffb82200b23e08e81f60/execroot/_main \ No newline at end of file diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index eac45e8d51..aaa583f1a9 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit eac45e8d5154ff52d0c706ab23f8b430925f7f6f +Subproject commit aaa583f1a9aabb558c4a35085f7bdeeb54f28390 diff --git a/tools/go_sfu/BUILD b/tools/go_sfu/BUILD deleted file mode 100644 index 4abfe207a0..0000000000 --- a/tools/go_sfu/BUILD +++ /dev/null @@ -1,18 +0,0 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_binary") - -go_binary( - name = "go_sfu", - srcs = glob(["*.go"]), - cgo = True, - linkmode = "c-archive", - visibility = ["//visibility:public"], - deps = [ - "@com_github_pion_datachannel//:datachannel", - "@com_github_pion_dtls_v3//:dtls", - "@com_github_pion_ice_v4//:ice", - "@com_github_pion_logging//:logging", - "@com_github_pion_rtcp//:rtcp", - "@com_github_pion_sctp//:sctp", - "@com_github_pion_srtp_v3//:srtp", - ], -) diff --git a/tools/go_sfu/CLAUDE.md b/tools/go_sfu/CLAUDE.md deleted file mode 100644 index ee33a94673..0000000000 --- a/tools/go_sfu/CLAUDE.md +++ /dev/null @@ -1,107 +0,0 @@ -# Go/Pion SFU - -The group call test mode uses a Go-based SFU (Selective Forwarding Unit) built with [Pion WebRTC](https://github.com/pion/webrtc), linked into the C++ `tgcalls_cli` binary via CGo. - -## Build Integration -- `MODULE.bazel` — `rules_go` 0.60.0 + Go SDK 1.24.2 + `gazelle` 0.43.0; Pion dependencies managed via `go_deps` Gazelle extension -- `tools/go_sfu/BUILD` — `go_binary` with `linkmode = "c-archive"` produces a static archive + CGo header, exposes `CcInfo` to C++ targets -- `tools/go_sfu/go.mod` / `go.sum` — Pion dependency declarations (pion/ice, pion/dtls, pion/srtp, pion/sctp) -- `tools/tgcalls_cli/BUILD` — depends on `//tools/go_sfu` to link the Go archive -- The CGo-generated header is included as `#include "tools/go_sfu/go_sfu.h"` in C++ code - -## How It Works -The `go_binary` with `linkmode = "c-archive"` compiles Go code (including the Go runtime) into a `.a` static archive. Functions annotated with `//export` in Go become C-callable symbols. Bazel's `rules_go` automatically provides `CcInfo`, so `cc_binary` targets can depend on the Go archive via `deps` — no manual linkopts needed. - -The Go runtime (GC, goroutine scheduler) runs inside the C++ process. This adds ~10MB memory overhead. `GoSfu_Init()` must be called before any other Go functions. - -## Key Files -- `tools/go_sfu/sfu.go` — SFU core: participant registry, join/leave/response flow, audio+video RTP forwarding, SSRC registry (audio/video/video-rtx with layer index), Colibri `ReceiverVideoConstraints`/`SenderVideoConstraints` handling, PLI/FIR forwarding, `ActiveAudioSsrcs`/`ActiveVideoSsrcs` broadcasting, `//export` C bindings (`GoSfu_Init`, `GoSfu_Create`, `GoSfu_Destroy`, `GoSfu_Join`, `GoSfu_Leave`, `GoSfu_QuerySsrc`, `GoSfu_QueryVideoSsrcs`, `GoSfu_Free`, `GoSfu_Shutdown`) -- `tools/go_sfu/participant.go` — per-participant transport stack (ICE agent, DTLS conn, SRTP session, SRTCP contexts for manual RTCP decrypt/encrypt, SCTP association, data channel send/receive, per-receiver video layer selection) -- `tools/go_sfu/mux.go` — packet demuxer: three-way split of ICE traffic into DTLS handshake, SRTP (RTP), and SRTCP (RTCP) channels per RFC 7983 + RFC 5761 -- `tools/go_sfu/go.mod` / `go.sum` — Go module with Pion dependencies -- `tools/tgcalls_cli/group_mode.cpp` — C++ side that drives the group join flow and calls into Go SFU -- `tools/tgcalls_cli/group_mode.h` — header for group mode entry point -- `tools/tgcalls_cli/group_participant.h/.cpp` — shared participant lifecycle helpers (`createParticipant`, `stopParticipant`, `validateGroupState`, `printGroupSummary`), `ParticipantState` struct, audio helpers -- `tools/tgcalls_cli/group_churn_mode.h/.cpp` — group-churn stress test: base group + rapid join/leave cycling - -## SFU Bandwidth Adaptation - -The SFU implements REMB-based bandwidth-adaptive simulcast layer selection for video. Per receiver, it maintains an EWMA-smoothed bandwidth estimate from REMB RTCP feedback and uses a `LayerSelector` state machine per (receiver, sender) pair to decide which simulcast layer to forward. - -### State Machine -- **STABLE**: forwarding current layer. Checks for upswitch opportunity (REMB > threshold × 1.2) or downswitch need (REMB < threshold × 0.7). -- **PROBING_UP**: ramping RTX padding from 0 to the gap between current and target layer bitrate over 2 seconds. Aborts if REMB drops; succeeds if REMB sustains. -- **GRACE_DOWN**: REMB below downswitch threshold. Waits 500ms, then downswitches if not recovered. 5-second cooldown after any switch. - -### Layer Thresholds -| Layer | Nominal | Upswitch When | Downswitch When | -|-------|---------|--------------|-----------------| -| 0 | 60 kbps | (start) | (never) | -| 1 | 110 kbps | BW > 132 kbps | BW < 77 kbps | -| 2 | 900 kbps | BW > 1,080 kbps | BW < 630 kbps | - -### Layer Selection and SSRC Rewriting -The SFU forwards exactly one simulcast layer per (receiver, sender) pair. Before `ReceiverVideoConstraints` arrives, the SFU uses `requestedLayer` as the cap and forwards at `maxActiveLayer` (the highest layer the encoder actually produces). After constraints arrive, `ensureLayerSelector` sets `selectedLayer` clamped to `maxActiveLayer`. - -When forwarding a non-base layer, the SFU rewrites the RTP SSRC to the primary (layer 0) SSRC. This is necessary because `IncomingVideoChannel` in CustomImpl attaches its `VideoSinkImpl` to `_mainVideoSsrc` (the first SSRC in the SIM group, i.e., layer 0). Without SSRC rewriting, packets from higher layers are delivered to the wrong receive stream and never decoded. RTX SSRCs are similarly rewritten to the layer 0 FID SSRC. - -### Testing on Localhost -Use `--network-scenario step-down-up` to exercise the full adaptation path via per-client network simulation (replaces the old REMB-override `--bw-scenario`). - -```bash -# Network scenario test (30s, 4 phases: uncapped → 80k egress → 200k → uncapped) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 30 --network-scenario step-down-up -``` - -Unit tests drive the `LayerSelector` state machine directly via mocked callbacks. Run from `tools/go_sfu/`: - -```bash -go test -run TestLayerSelector -v -timeout 60s -``` - -Covers upswitch L0→L1→L2, downswitch L2→L1→L0, grace-down recovery on transient dips, stale-BW idle behavior, the `OnMaxActiveLayerIncreased` fallback used when clients don't send REMB, and `maxLayer` enforcement. - -### REMB-free Fallback - -Real tgcalls clients negotiate `goog-remb` but use transport-cc as the primary BWE signal, so no REMB actually arrives at the SFU. This means the REMB-driven state machine never enters `PROBING_UP` in live runs. `LayerSelector.OnMaxActiveLayerIncreased(maxActive)` is the fallback: when the sender starts producing a higher simulcast layer than previously seen AND the BW estimate is stale, the SFU immediately upshifts to the highest available layer (clamped by `maxLayer`). Called from `sfu.go`'s packet-forwarding path whenever `maxActiveLayer[senderID]` is bumped. - -### Key Files -- `tools/go_sfu/bandwidth.go` — `BandwidthEstimator`, `LayerSelector`, `RtxRingBuffer`, `OnMaxActiveLayerIncreased` -- `tools/go_sfu/bandwidth_test.go` — unit tests for `LayerSelector` up/down transitions -- `tools/go_sfu/participant.go` — REMB parsing in `readRTCPLoop()`, `selectedLayers` -- `tools/go_sfu/sfu.go` — layer-filtered forwarding, `ensureLayerSelector`, SSRC rewriting, `maxActiveLayer` tracking - -## SFU Transport-CC Feedback - -The SFU generates RTCP transport-cc feedback (type 205, FMT 15) per sender every 100ms. This provides the sender's GCC (Google Congestion Control) with packet arrival data, enabling BWE ramp-up so the encoder produces higher simulcast layers. - -The feedback reflects actual (or simulated) packet arrivals — if ingress network simulation drops packets, the feedback reports them as missing, causing the sender's GCC to reduce bitrate. - -### How It Works -1. Each incoming RTP packet is parsed for the transport-wide sequence number (header extension ID 3, one-byte RFC 5285 format) -2. `TransportCCGenerator.RecordArrival(twccSeq)` records the arrival time -3. Every 100ms, `emitFeedback()` builds an `rtcp.TransportLayerCC` packet with `PacketChunks` (run-length or status-vector encoding) and `RecvDeltas` (250µs units) -4. The feedback is marshalled, encrypted via SRTCP, and sent to the sender -5. The sender's `Call::Receiver::DeliverRtcpPacket()` feeds it to the GCC via `GroupNetworkManager::OnRtcpPacketReceived_n` → `_call->Receiver()->DeliverRtcpPacket()` - -### Current Status -Transport-cc feedback is working: the SFU records ~60-70 arrivals per first 100ms tick, generates feedback packets (32-128 bytes), and the sender receives them. The GCC ramps from the 400kbps start bitrate to produce layer 1 (640x360). Full ramp to layer 2 (1280x720, needs ~1Mbps) requires further investigation — the GCC may need probing support or the `adjustBitratePreferences` max_bitrate_bps of 1052kbps may be a bottleneck. - -### Key Files -- `tools/go_sfu/twcc.go` — `TransportCCGenerator`, `parseTWCCSeq` (RTP header extension parser) - -## SFU Network Simulation - -Per-client network simulation with independent ingress (from client) and egress (to client) simulators. Each direction has: delay, jitter, packet loss, and bandwidth cap (token bucket). - -```bash -# Configure via CGo: GoSfu_SetNetworkParams(handle, participantID, direction, delayMs, jitterMs, dropRate, bandwidthBps) -# direction: 0 = ingress, 1 = egress - -# Network scenario test (4 phases: uncapped -> 80k -> 200k -> uncapped) -./bazel-bin/tools/tgcalls_cli/tgcalls_cli --mode group --participants 2 --video --duration 30 --network-scenario step-down-up -``` - -### Key Files -- `tools/go_sfu/network_sim.go` — `NetworkSimulator` (token bucket, delay, jitter, drop) -- `tools/go_sfu/participant.go` — `ingressSim`, `egressSim` on each `Participant` diff --git a/tools/go_sfu/bandwidth.go b/tools/go_sfu/bandwidth.go deleted file mode 100644 index 47b3e1610f..0000000000 --- a/tools/go_sfu/bandwidth.go +++ /dev/null @@ -1,475 +0,0 @@ -package main - -import ( - "sync" - "time" -) - -// --- Bandwidth Estimation --- - -const ( - ewmaAlpha = 0.3 - safetyFactor = 0.85 - stalenessTTL = 5 * time.Second -) - -// BandwidthEstimator maintains an EWMA-smoothed REMB estimate for a receiver. -type BandwidthEstimator struct { - mu sync.Mutex - lastREMBBps float64 - smoothedBps float64 - lastREMBAt time.Time -} - -// OnREMB feeds a new REMB value (in bits per second) into the estimator. -func (e *BandwidthEstimator) OnREMB(bps float64) { - e.mu.Lock() - defer e.mu.Unlock() - e.lastREMBBps = bps - e.lastREMBAt = time.Now() - if e.smoothedBps == 0 { - e.smoothedBps = bps - } else { - e.smoothedBps = ewmaAlpha*bps + (1-ewmaAlpha)*e.smoothedBps - } -} - -// EffectiveBps returns the safe bandwidth estimate in bps. -// Returns -1 if the estimate is stale (no REMB for stalenessTTL). -func (e *BandwidthEstimator) EffectiveBps() float64 { - e.mu.Lock() - defer e.mu.Unlock() - if e.lastREMBAt.IsZero() || time.Since(e.lastREMBAt) > stalenessTTL { - return -1 - } - return e.smoothedBps * safetyFactor -} - -// SmoothedBps returns the raw EWMA value (for logging). -func (e *BandwidthEstimator) SmoothedBps() float64 { - e.mu.Lock() - defer e.mu.Unlock() - return e.smoothedBps -} - -// LastREMBBps returns the last raw REMB value (for logging). -func (e *BandwidthEstimator) LastREMBBps() float64 { - e.mu.Lock() - defer e.mu.Unlock() - return e.lastREMBBps -} - -// --- Layer Bitrate Model --- - -// LayerBitrate holds the thresholds for one simulcast layer. -type LayerBitrate struct { - Nominal float64 // typical sustained bitrate (bps) - UpThresh float64 // effective BW must exceed this to upswitch TO this layer - DownThresh float64 // effective BW must drop below this to downswitch FROM this layer -} - -// layerBitrates defines the 3 simulcast layers matching tgcalls adjustVideoSendParams(). -// Layer 0 has no downThresh (always viable) and no upThresh (start here). -var layerBitrates = [3]LayerBitrate{ - {Nominal: 60_000, UpThresh: 0, DownThresh: 0}, // layer 0: 160x90 - {Nominal: 110_000, UpThresh: 132_000, DownThresh: 77_000}, // layer 1: 320x180 - {Nominal: 900_000, UpThresh: 1_080_000, DownThresh: 630_000}, // layer 2: 640x360 -} - -// --- RTX Ring Buffer --- - -// RtxEntry stores one video RTP packet for potential retransmission as RTX padding. -type RtxEntry struct { - Payload []byte - SeqNum uint16 - Timestamp uint32 -} - -// RtxRingBuffer is a per-sender circular buffer of recent video RTP packets. -type RtxRingBuffer struct { - mu sync.Mutex - entries []RtxEntry - head int - count int - cap int -} - -// NewRtxRingBuffer creates a ring buffer with the given capacity. -func NewRtxRingBuffer(capacity int) *RtxRingBuffer { - return &RtxRingBuffer{ - entries: make([]RtxEntry, capacity), - cap: capacity, - } -} - -// Push adds a video RTP packet to the ring buffer. -// payload is copied so the caller can reuse their buffer. -func (r *RtxRingBuffer) Push(payload []byte, seqNum uint16, timestamp uint32) { - r.mu.Lock() - defer r.mu.Unlock() - entry := &r.entries[r.head] - if cap(entry.Payload) >= len(payload) { - entry.Payload = entry.Payload[:len(payload)] - } else { - entry.Payload = make([]byte, len(payload)) - } - copy(entry.Payload, payload) - entry.SeqNum = seqNum - entry.Timestamp = timestamp - r.head = (r.head + 1) % r.cap - if r.count < r.cap { - r.count++ - } -} - -// Get returns up to n most recent packets (oldest first). -func (r *RtxRingBuffer) Get(n int) []RtxEntry { - r.mu.Lock() - defer r.mu.Unlock() - if n > r.count { - n = r.count - } - if n == 0 { - return nil - } - result := make([]RtxEntry, n) - start := (r.head - r.count + r.cap) % r.cap // oldest entry - readFrom := (start + r.count - n + r.cap) % r.cap // start of the n most recent - for i := 0; i < n; i++ { - idx := (readFrom + i) % r.cap - src := &r.entries[idx] - entry := RtxEntry{ - Payload: make([]byte, len(src.Payload)), - SeqNum: src.SeqNum, - Timestamp: src.Timestamp, - } - copy(entry.Payload, src.Payload) - result[i] = entry - } - return result -} - -// rtxEncapsulate wraps an original RTP payload into an RTX packet payload per RFC 4588. -// The RTX payload is: [2-byte original sequence number] + [original RTP payload (after header)]. -// The caller is responsible for setting the RTX SSRC and incrementing RTX sequence number -// on the outer RTP header. -func rtxEncapsulate(originalPayload []byte, originalSeqNum uint16) []byte { - out := make([]byte, 2+len(originalPayload)) - out[0] = byte(originalSeqNum >> 8) - out[1] = byte(originalSeqNum) - copy(out[2:], originalPayload) - return out -} - -// --- Layer Selector State Machine --- - -type selectorState int - -const ( - stateStable selectorState = iota - stateProbingUp - stateGraceDown -) - -func (s selectorState) String() string { - switch s { - case stateStable: - return "STABLE" - case stateProbingUp: - return "PROBING_UP" - case stateGraceDown: - return "GRACE_DOWN" - default: - return "UNKNOWN" - } -} - -const ( - probeDuration = 2 * time.Second - graceDownTimeout = 500 * time.Millisecond - cooldownDuration = 5 * time.Second - tickInterval = 100 * time.Millisecond -) - -// LayerSelectorCallbacks provides the hooks the state machine needs into the SFU. -type LayerSelectorCallbacks struct { - // GetEffectiveBW returns the receiver's current effective bandwidth (bps), or -1 if stale. - GetEffectiveBW func() float64 - // SetSelectedLayer updates the forwarding layer for this (receiver, sender) pair. - SetSelectedLayer func(layer int) - // SendPLI sends a PLI to the sender for the given SSRC. - SendPLI func(ssrc uint32) - // GetSenderVideoLayers returns the sender's simulcast layers. - GetSenderVideoLayers func() []SimulcastLayer - // GetRtxBuffer returns the sender's RTX ring buffer. - GetRtxBuffer func() *RtxRingBuffer - // SendRtxPadding sends an RTX padding packet to the receiver. - // rtxSSRC is the FID SSRC, seqNum is the RTX sequence number. - SendRtxPadding func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) - // Log emits a log message. - Log func(level string, format string, args ...interface{}) -} - -// LayerSelector manages the state machine for one (receiver, sender) pair. -type LayerSelector struct { - mu sync.Mutex - receiverID int - senderID int - currentLayer int - maxLayer int // max layer the receiver requested - state selectorState - callbacks LayerSelectorCallbacks - - // Probing state - probeTarget int // layer we're probing toward - probeStartTime time.Time - probeRtxSeq uint16 // incrementing RTX sequence number for padding - - // Grace-down state - graceStartTime time.Time - - // Cooldown - lastSwitchTime time.Time - - // Control - stopCh chan struct{} - done chan struct{} -} - -// NewLayerSelector creates and starts a new LayerSelector. -// initialLayer is the layer to start forwarding (typically = requestedLayer). -func NewLayerSelector(receiverID, senderID, initialLayer, maxLayer int, cb LayerSelectorCallbacks) *LayerSelector { - ls := &LayerSelector{ - receiverID: receiverID, - senderID: senderID, - currentLayer: initialLayer, - maxLayer: maxLayer, - state: stateStable, - callbacks: cb, - stopCh: make(chan struct{}), - done: make(chan struct{}), - } - go ls.run() - return ls -} - -// Stop terminates the selector's tick loop. -func (ls *LayerSelector) Stop() { - close(ls.stopCh) - <-ls.done -} - -// SetMaxLayer updates the maximum layer the receiver wants (from ReceiverVideoConstraints). -func (ls *LayerSelector) SetMaxLayer(maxLayer int) { - ls.mu.Lock() - defer ls.mu.Unlock() - ls.maxLayer = maxLayer - // If current layer exceeds new max, downswitch immediately. - if ls.currentLayer > maxLayer { - ls.switchLayer(maxLayer) - } -} - -// OnMaxActiveLayerIncreased is called when the sender starts producing a -// higher simulcast layer than previously observed. If the BW estimate is -// stale (no REMB arriving — common when clients use transport-cc exclusively -// and the SFU hasn't generated REMB), upshift immediately up to maxLayer so -// the receiver gets the best available layer. When REMB is fresh, the state -// machine is in charge and this is a no-op. -func (ls *LayerSelector) OnMaxActiveLayerIncreased(maxActive int) { - ls.mu.Lock() - defer ls.mu.Unlock() - if ls.callbacks.GetEffectiveBW() >= 0 { - // BW estimate available — state machine decides. - return - } - target := maxActive - if target > ls.maxLayer { - target = ls.maxLayer - } - if target > ls.currentLayer { - ls.switchLayer(target) - } -} - -// CurrentLayer returns the currently selected layer. -func (ls *LayerSelector) CurrentLayer() int { - ls.mu.Lock() - defer ls.mu.Unlock() - return ls.currentLayer -} - -func (ls *LayerSelector) run() { - defer close(ls.done) - ticker := time.NewTicker(tickInterval) - defer ticker.Stop() - - for { - select { - case <-ls.stopCh: - return - case <-ticker.C: - ls.tick() - } - } -} - -func (ls *LayerSelector) tick() { - ls.mu.Lock() - defer ls.mu.Unlock() - - effectiveBW := ls.callbacks.GetEffectiveBW() - if effectiveBW < 0 { - // Stale estimate — do nothing. - return - } - - switch ls.state { - case stateStable: - ls.tickStable(effectiveBW) - case stateProbingUp: - ls.tickProbingUp(effectiveBW) - case stateGraceDown: - ls.tickGraceDown(effectiveBW) - } -} - -func (ls *LayerSelector) tickStable(effectiveBW float64) { - // Check for upswitch opportunity. - nextLayer := ls.currentLayer + 1 - if nextLayer <= ls.maxLayer && nextLayer <= 2 { - if !ls.inCooldown() && effectiveBW > layerBitrates[nextLayer].UpThresh { - ls.state = stateProbingUp - ls.probeTarget = nextLayer - ls.probeStartTime = time.Now() - ls.callbacks.Log("INFO", "Participant %d<-%d: STABLE->PROBING_UP (BW=%.0fkbps, target=layer%d@%.0fkbps)", - ls.receiverID, ls.senderID, effectiveBW/1000, nextLayer, layerBitrates[nextLayer].UpThresh/1000) - return - } - } - - // Check for downswitch need. - if ls.currentLayer > 0 { - if effectiveBW < layerBitrates[ls.currentLayer].DownThresh { - ls.state = stateGraceDown - ls.graceStartTime = time.Now() - ls.callbacks.Log("INFO", "Participant %d<-%d: STABLE->GRACE_DOWN (BW=%.0fkbps, thresh=%.0fkbps)", - ls.receiverID, ls.senderID, effectiveBW/1000, layerBitrates[ls.currentLayer].DownThresh/1000) - return - } - } -} - -func (ls *LayerSelector) tickProbingUp(effectiveBW float64) { - elapsed := time.Since(ls.probeStartTime) - - // Abort if bandwidth dropped below current layer's nominal bitrate. - if effectiveBW < layerBitrates[ls.currentLayer].Nominal { - ls.state = stateStable - ls.lastSwitchTime = time.Now() // enter cooldown - ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (abort, BW=%.0fkbps < nominal=%.0fkbps)", - ls.receiverID, ls.senderID, effectiveBW/1000, layerBitrates[ls.currentLayer].Nominal/1000) - return - } - - // Probe complete — switch up. - if elapsed >= probeDuration { - if effectiveBW > layerBitrates[ls.probeTarget].Nominal { - ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (success, switching to layer %d)", - ls.receiverID, ls.senderID, ls.probeTarget) - ls.switchLayer(ls.probeTarget) - return - } - // BW not sufficient at end of probe — abort. - ls.state = stateStable - ls.lastSwitchTime = time.Now() - ls.callbacks.Log("INFO", "Participant %d<-%d: PROBING_UP->STABLE (probe done but BW=%.0fkbps insufficient)", - ls.receiverID, ls.senderID, effectiveBW/1000) - return - } - - // Send RTX padding during probe. - ls.sendProbePadding(elapsed) -} - -func (ls *LayerSelector) tickGraceDown(effectiveBW float64) { - // If bandwidth recovered, cancel grace period. - if effectiveBW >= layerBitrates[ls.currentLayer].DownThresh { - ls.state = stateStable - ls.callbacks.Log("INFO", "Participant %d<-%d: GRACE_DOWN->STABLE (recovered, BW=%.0fkbps)", - ls.receiverID, ls.senderID, effectiveBW/1000) - return - } - - // Grace period expired — downswitch. - if time.Since(ls.graceStartTime) >= graceDownTimeout { - targetLayer := ls.currentLayer - 1 - if targetLayer < 0 { - targetLayer = 0 - } - ls.callbacks.Log("INFO", "Participant %d<-%d: GRACE_DOWN->STABLE (downswitch to layer %d)", - ls.receiverID, ls.senderID, targetLayer) - ls.switchLayer(targetLayer) - } -} - -func (ls *LayerSelector) switchLayer(newLayer int) { - oldLayer := ls.currentLayer - ls.currentLayer = newLayer - ls.state = stateStable - ls.lastSwitchTime = time.Now() - ls.callbacks.SetSelectedLayer(newLayer) - - // Request keyframe at the new layer. - layers := ls.callbacks.GetSenderVideoLayers() - if newLayer < len(layers) { - ls.callbacks.SendPLI(layers[newLayer].SSRC) - ls.callbacks.Log("INFO", "Participant %d<-%d: switched layer %d->%d (PLI sent for SSRC=%d)", - ls.receiverID, ls.senderID, oldLayer, newLayer, layers[newLayer].SSRC) - } -} - -func (ls *LayerSelector) inCooldown() bool { - return !ls.lastSwitchTime.IsZero() && time.Since(ls.lastSwitchTime) < cooldownDuration -} - -func (ls *LayerSelector) sendProbePadding(elapsed time.Duration) { - // Calculate target padding rate: ramp from 0 to gap over probeDuration. - gap := layerBitrates[ls.probeTarget].Nominal - layerBitrates[ls.currentLayer].Nominal - progress := float64(elapsed) / float64(probeDuration) - targetBps := gap * progress - - // How many bytes to send in this 100ms tick. - bytesPerTick := targetBps / 8 / (float64(time.Second) / float64(tickInterval)) - - rtxBuf := ls.callbacks.GetRtxBuffer() - if rtxBuf == nil { - return - } - - // Pull packets from the ring buffer to fill the target bytes. - entries := rtxBuf.Get(20) // enough for one tick - if len(entries) == 0 { - return - } - - layers := ls.callbacks.GetSenderVideoLayers() - if ls.currentLayer >= len(layers) { - return - } - rtxSSRC := layers[ls.currentLayer].FidSSRC - if rtxSSRC == 0 { - return - } - - var sentBytes float64 - entryIdx := 0 - for sentBytes < bytesPerTick && entryIdx < len(entries) { - entry := entries[entryIdx] - entryIdx++ - rtxPayload := rtxEncapsulate(entry.Payload, entry.SeqNum) - ls.probeRtxSeq++ - ls.callbacks.SendRtxPadding(rtxPayload, rtxSSRC, ls.probeRtxSeq, entry.Timestamp) - sentBytes += float64(len(rtxPayload)) - } -} diff --git a/tools/go_sfu/bandwidth_test.go b/tools/go_sfu/bandwidth_test.go deleted file mode 100644 index e8ce371b8e..0000000000 --- a/tools/go_sfu/bandwidth_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package main - -import ( - "fmt" - "sync" - "sync/atomic" - "testing" - "time" -) - -// mockCallbacks is a test harness for driving the LayerSelector. BW is -// atomic so the selector's run() goroutine can read while the test writes. -type mockCallbacks struct { - bw atomic.Int64 // current effective bandwidth in bps; negative = stale - layers []SimulcastLayer - selectedLayer atomic.Int32 - pliCount atomic.Int32 - probePaddings atomic.Int32 - - logMu sync.Mutex - logBuf []string -} - -func newMockCallbacks(layers []SimulcastLayer, initialBW float64) *mockCallbacks { - m := &mockCallbacks{layers: layers} - m.bw.Store(int64(initialBW)) - m.selectedLayer.Store(-1) - return m -} - -func (m *mockCallbacks) setBW(bps float64) { m.bw.Store(int64(bps)) } - -func (m *mockCallbacks) currentSelected() int { return int(m.selectedLayer.Load()) } - -func (m *mockCallbacks) toCallbacks() LayerSelectorCallbacks { - return LayerSelectorCallbacks{ - GetEffectiveBW: func() float64 { - v := float64(m.bw.Load()) - if v < 0 { - return -1 - } - return v - }, - SetSelectedLayer: func(layer int) { - m.selectedLayer.Store(int32(layer)) - }, - SendPLI: func(ssrc uint32) { - m.pliCount.Add(1) - }, - GetSenderVideoLayers: func() []SimulcastLayer { - return m.layers - }, - GetRtxBuffer: func() *RtxRingBuffer { - return nil // probing padding no-ops without a buffer - }, - SendRtxPadding: func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) { - m.probePaddings.Add(1) - }, - Log: func(level string, format string, args ...interface{}) { - m.logMu.Lock() - m.logBuf = append(m.logBuf, fmt.Sprintf("["+level+"] "+format, args...)) - m.logMu.Unlock() - }, - } -} - -// waitForLayer polls the selector's currentLayer up to timeout for a change -// to `want`. Returns true if reached, false on timeout. -func waitForLayer(ls *LayerSelector, want int, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if ls.CurrentLayer() == want { - return true - } - time.Sleep(20 * time.Millisecond) - } - return false -} - -func testLayers() []SimulcastLayer { - return []SimulcastLayer{ - {SSRC: 1001, FidSSRC: 1002}, - {SSRC: 1003, FidSSRC: 1004}, - {SSRC: 1005, FidSSRC: 1006}, - } -} - -// TestLayerSelectorUpswitch verifies L0 -> L1 -> L2 based on rising BW. -// -// Thresholds (from layerBitrates): -// -// L1 UpThresh = 132 kbps → needs REMB > ~155 kbps (with 0.85 safety factor) -// L2 UpThresh = 1080 kbps → needs REMB > ~1271 kbps -// -// The selector's state machine enforces a 5s cooldown after each switch, so -// the whole test runs in ~8-10 seconds. -func TestLayerSelectorUpswitch(t *testing.T) { - m := newMockCallbacks(testLayers(), 200_000) // > L1 UpThresh - ls := NewLayerSelector(1, 0, 0, 2, m.toCallbacks()) - defer ls.Stop() - - // L0 -> L1: should enter PROBING_UP within 150ms (one tick), then - // complete the 2s probe and switch to L1. - if !waitForLayer(ls, 1, 3*time.Second) { - t.Fatalf("L0->L1 upswitch timed out; currentLayer=%d selected=%d", ls.CurrentLayer(), m.currentSelected()) - } - if got := m.currentSelected(); got != 1 { - t.Fatalf("after L1 upswitch, SetSelectedLayer was not called with 1 (got %d)", got) - } - if pli := m.pliCount.Load(); pli < 1 { - t.Fatalf("expected at least 1 PLI on layer switch, got %d", pli) - } - - // L1 -> L2: raise BW above L2 UpThresh. Wait out the 5s cooldown and - // then the 2s probe (total ~7-8s). - m.setBW(1_500_000) - if !waitForLayer(ls, 2, 10*time.Second) { - t.Fatalf("L1->L2 upswitch timed out; currentLayer=%d", ls.CurrentLayer()) - } - if got := m.currentSelected(); got != 2 { - t.Fatalf("after L2 upswitch, SetSelectedLayer was not called with 2 (got %d)", got) - } -} - -// TestLayerSelectorDownswitch verifies L2 -> L1 -> L0 based on falling BW. -// Starts the selector pre-positioned at L2 by setting its state directly -// via `switchLayer`-equivalent initial-layer argument, then drives BW down. -// -// Thresholds: -// -// L2 DownThresh = 630 kbps → needs REMB < ~741 kbps -// L1 DownThresh = 77 kbps → needs REMB < ~91 kbps -// -// Downswitches are governed by a 500ms grace period, no cooldown, so this -// test runs in ~1.5 seconds. -func TestLayerSelectorDownswitch(t *testing.T) { - m := newMockCallbacks(testLayers(), 1_500_000) // high BW, at L2 - ls := NewLayerSelector(1, 0, 2, 2, m.toCallbacks()) - defer ls.Stop() - - // Drop BW below L2 downswitch threshold. Effective = 500k * 0.85 = 425k - // is NOT below 630k effective threshold directly. Use 600k raw so - // effective = 510k, well below 630k. - m.setBW(600_000) - if !waitForLayer(ls, 1, 2*time.Second) { - t.Fatalf("L2->L1 downswitch timed out; currentLayer=%d", ls.CurrentLayer()) - } - if got := m.currentSelected(); got != 1 { - t.Fatalf("after L1 downswitch, SetSelectedLayer was not called with 1 (got %d)", got) - } - - // Drop below L1 downswitch threshold (77k effective → raw < 91k). - // Use 50k raw → effective 42k. - m.setBW(50_000) - if !waitForLayer(ls, 0, 2*time.Second) { - t.Fatalf("L1->L0 downswitch timed out; currentLayer=%d", ls.CurrentLayer()) - } - if got := m.currentSelected(); got != 0 { - t.Fatalf("after L0 downswitch, SetSelectedLayer was not called with 0 (got %d)", got) - } -} - -// TestLayerSelectorGraceDownRecovery verifies that a transient BW dip that -// recovers within the 500ms grace window does NOT cause a downswitch. -func TestLayerSelectorGraceDownRecovery(t *testing.T) { - m := newMockCallbacks(testLayers(), 1_500_000) - ls := NewLayerSelector(1, 0, 2, 2, m.toCallbacks()) - defer ls.Stop() - - // Dip below downthresh, then recover before grace expires. - m.setBW(500_000) // below L2 downthresh - time.Sleep(300 * time.Millisecond) - m.setBW(1_500_000) // recovered - time.Sleep(500 * time.Millisecond) - - if got := ls.CurrentLayer(); got != 2 { - t.Fatalf("transient dip should not have downswitched; currentLayer=%d", got) - } -} - -// TestLayerSelectorStaleBW verifies that with no REMB data (BW=-1), the -// state machine does not transition. -func TestLayerSelectorStaleBW(t *testing.T) { - m := newMockCallbacks(testLayers(), -1) // stale - ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) - defer ls.Stop() - - time.Sleep(1 * time.Second) - if got := ls.CurrentLayer(); got != 1 { - t.Fatalf("stale BW should not trigger a transition; currentLayer=%d", got) - } -} - -// TestLayerSelectorOnMaxActiveLayerIncreasedWhenStale verifies the fallback -// path: when BW is stale (clients don't send REMB), discovery of a higher -// active layer from the sender causes an immediate upswitch. -func TestLayerSelectorOnMaxActiveLayerIncreasedWhenStale(t *testing.T) { - m := newMockCallbacks(testLayers(), -1) - ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) - defer ls.Stop() - - // Nothing has happened yet. - if got := ls.CurrentLayer(); got != 1 { - t.Fatalf("unexpected initial layer %d", got) - } - - // Sender starts producing L2. With stale BW, we should upshift - // immediately up to the receiver's requested maxLayer. - ls.OnMaxActiveLayerIncreased(2) - if got := ls.CurrentLayer(); got != 2 { - t.Fatalf("expected upshift to L2 on maxActive increase with stale BW; got %d", got) - } - if got := m.currentSelected(); got != 2 { - t.Fatalf("SetSelectedLayer should have been called with 2; got %d", got) - } -} - -// TestLayerSelectorOnMaxActiveLayerIncreasedWhenFresh verifies that when BW -// is fresh, OnMaxActiveLayerIncreased is a no-op — the state machine is in -// charge of layer selection. -func TestLayerSelectorOnMaxActiveLayerIncreasedWhenFresh(t *testing.T) { - m := newMockCallbacks(testLayers(), 200_000) // fresh, enough for L1 only - ls := NewLayerSelector(1, 0, 1, 2, m.toCallbacks()) - defer ls.Stop() - - ls.OnMaxActiveLayerIncreased(2) - if got := ls.CurrentLayer(); got != 1 { - t.Fatalf("fresh BW should leave state machine in charge; current=%d", got) - } -} - -// TestLayerSelectorRespectsMaxLayer verifies that upswitches never exceed -// the receiver's requested maxLayer. -func TestLayerSelectorRespectsMaxLayer(t *testing.T) { - m := newMockCallbacks(testLayers(), 2_000_000) // way more than needed for L2 - ls := NewLayerSelector(1, 0, 0, 1, m.toCallbacks()) - defer ls.Stop() - - // Wait long enough for an L0->L1 upswitch (~2.2s). Then wait past the - // cooldown (5s) plus another probe window (2s) to ensure the selector - // does NOT attempt to probe beyond maxLayer=1. - if !waitForLayer(ls, 1, 3*time.Second) { - t.Fatalf("L0->L1 upswitch timed out") - } - time.Sleep(8 * time.Second) - if got := ls.CurrentLayer(); got != 1 { - t.Fatalf("selector upshifted beyond maxLayer=1; got %d", got) - } -} diff --git a/tools/go_sfu/go.mod b/tools/go_sfu/go.mod deleted file mode 100644 index 0cdde5f9a6..0000000000 --- a/tools/go_sfu/go.mod +++ /dev/null @@ -1,27 +0,0 @@ -module github.com/nicegram/AltTgCalls/tools/go_sfu - -go 1.24.2 - -require ( - github.com/pion/datachannel v1.5.10 - github.com/pion/dtls/v3 v3.0.6 - github.com/pion/ice/v4 v4.0.7 - github.com/pion/logging v0.2.3 - github.com/pion/rtcp v1.2.15 - github.com/pion/sctp v1.8.37 - github.com/pion/srtp/v3 v3.0.5 -) - -require ( - github.com/google/uuid v1.6.0 // indirect - github.com/pion/mdns/v2 v2.0.7 // indirect - github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtp v1.8.17 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect - github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect - github.com/wlynxg/anet v0.0.3 // indirect - golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect -) diff --git a/tools/go_sfu/go.sum b/tools/go_sfu/go.sum deleted file mode 100644 index 5c1611482a..0000000000 --- a/tools/go_sfu/go.sum +++ /dev/null @@ -1,44 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= -github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= -github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= -github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= -github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= -github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.17 h1:CFhaPN8Ikt9Sk7B3pic0kfwVia2dUMEtPSL34Gvihjw= -github.com/pion/rtp v1.8.17/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo= -github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/go_sfu/mux.go b/tools/go_sfu/mux.go deleted file mode 100644 index 2fb31b4853..0000000000 --- a/tools/go_sfu/mux.go +++ /dev/null @@ -1,239 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net" - "sync" - "time" -) - -const ( - muxReadBufSize = 8192 - muxChanBufSize = 256 -) - -// isDTLS returns true if the first byte indicates a DTLS record (RFC 7983: 20–63). -func isDTLS(b byte) bool { - return b >= 20 && b <= 63 -} - -// isRTPOrRTCP returns true if the first byte indicates an RTP/RTCP packet (RFC 7983: 128–191). -func isRTPOrRTCP(b byte) bool { - return b >= 128 && b <= 191 -} - -// isRTCP returns true if the packet is RTCP (not RTP) per RFC 5761 Section 4. -// RTCP packet types (byte[1]) are 200-211. RTP with Marker=1 and dynamic PT >= 96 -// gives byte[1] >= 224, so we use byte[1] >= 200 && byte[1] < 224 to exclude RTP. -// In SRTCP the fixed header is unencrypted, so byte[1] is readable. -func isRTCP(pkt []byte) bool { - return len(pkt) >= 2 && pkt[1] >= 200 && pkt[1] < 224 -} - -// PacketDemux reads from a net.Conn and routes packets to separate DTLS, -// SRTP (RTP only), and RTCP channels based on RFC 7983 first-byte classification -// and RTP/RTCP payload type demux. -type PacketDemux struct { - conn net.Conn - dtlsCh chan []byte - srtpCh chan []byte - rtcpCh chan []byte - once sync.Once - closed chan struct{} - label string -} - -func (d *PacketDemux) logf(format string, args ...interface{}) { - fmt.Printf("[demux-%s] %s\n", d.label, fmt.Sprintf(format, args...)) -} - -// NewPacketDemux creates a PacketDemux and starts the read loop goroutine. -func NewPacketDemux(conn net.Conn, label string) *PacketDemux { - d := &PacketDemux{ - conn: conn, - dtlsCh: make(chan []byte, muxChanBufSize), - srtpCh: make(chan []byte, muxChanBufSize), - rtcpCh: make(chan []byte, muxChanBufSize), - closed: make(chan struct{}), - label: label, - } - go d.readLoop() - return d -} - -func (d *PacketDemux) readLoop() { - buf := make([]byte, muxReadBufSize) - dtlsCount := 0 - srtpCount := 0 - rtcpCount := 0 - otherCount := 0 - for { - n, err := d.conn.Read(buf) - if err != nil { - d.Close() - return - } - if n == 0 { - continue - } - pkt := make([]byte, n) - copy(pkt, buf[:n]) - - switch { - case isDTLS(pkt[0]): - dtlsCount++ - if dtlsCount <= 5 { - d.logf("DTLS packet #%d: %d bytes (first byte: 0x%02x)", dtlsCount, n, pkt[0]) - } - select { - case d.dtlsCh <- pkt: - default: - d.logf("DTLS channel full, dropping packet") - } - case isRTPOrRTCP(pkt[0]): - if isRTCP(pkt) { - rtcpCount++ - if rtcpCount <= 3 { - d.logf("RTCP packet #%d: %d bytes (type byte: 0x%02x)", rtcpCount, n, pkt[1]) - } - select { - case d.rtcpCh <- pkt: - default: - // drop if channel full - } - } else { - srtpCount++ - if srtpCount == 1 { - d.logf("First SRTP packet: %d bytes", n) - } - select { - case d.srtpCh <- pkt: - default: - // drop if channel full - } - } - default: - otherCount++ - if otherCount <= 3 { - d.logf("Other packet: %d bytes (first byte: 0x%02x)", n, pkt[0]) - } - } - } -} - -// Close shuts down the demuxer and the underlying connection. -func (d *PacketDemux) Close() error { - var err error - d.once.Do(func() { - close(d.closed) - err = d.conn.Close() - }) - return err -} - -// DTLSEndpoint returns a net.Conn that yields only DTLS packets. -func (d *PacketDemux) DTLSEndpoint() net.Conn { - return &demuxEndpoint{demux: d, ch: d.dtlsCh} -} - -// SRTPEndpoint returns a net.Conn that yields only SRTP (RTP) packets. -// RTCP packets are routed to RTCPChannel() instead. -func (d *PacketDemux) SRTPEndpoint() net.Conn { - return &demuxEndpoint{demux: d, ch: d.srtpCh} -} - -// RTCPChannel returns a channel that receives raw encrypted SRTCP packets. -// These must be decrypted externally (not via SessionSRTP which only handles RTP). -func (d *PacketDemux) RTCPChannel() <-chan []byte { - return d.rtcpCh -} - -// demuxEndpoint implements net.Conn for a single demux channel. -type demuxEndpoint struct { - demux *PacketDemux - ch chan []byte - mu sync.Mutex - leftover []byte -} - -func (e *demuxEndpoint) Read(b []byte) (int, error) { - e.mu.Lock() - if len(e.leftover) > 0 { - n := copy(b, e.leftover) - e.leftover = e.leftover[n:] - if len(e.leftover) == 0 { - e.leftover = nil - } - e.mu.Unlock() - return n, nil - } - e.mu.Unlock() - - select { - case <-e.demux.closed: - return 0, io.EOF - case pkt, ok := <-e.ch: - if !ok { - return 0, io.EOF - } - n := copy(b, pkt) - if n < len(pkt) { - e.mu.Lock() - e.leftover = pkt[n:] - e.mu.Unlock() - } - return n, nil - } -} - -func (e *demuxEndpoint) Write(b []byte) (int, error) { - return e.demux.conn.Write(b) -} - -func (e *demuxEndpoint) Close() error { - return e.demux.Close() -} - -func (e *demuxEndpoint) LocalAddr() net.Addr { - return e.demux.conn.LocalAddr() -} - -func (e *demuxEndpoint) RemoteAddr() net.Addr { - return e.demux.conn.RemoteAddr() -} - -func (e *demuxEndpoint) SetDeadline(t time.Time) error { - return e.demux.conn.SetDeadline(t) -} - -func (e *demuxEndpoint) SetReadDeadline(t time.Time) error { - return e.demux.conn.SetReadDeadline(t) -} - -func (e *demuxEndpoint) SetWriteDeadline(t time.Time) error { - return e.demux.conn.SetWriteDeadline(t) -} - -// connToPacketConn wraps a net.Conn into a net.PacketConn. -// It is used to adapt a demuxEndpoint for pion/dtls.Server(), which -// requires net.PacketConn. Since the endpoint is already bound to a -// single peer, ReadFrom returns the conn's RemoteAddr and WriteTo ignores -// the addr parameter. -type connToPacketConn struct { - net.Conn -} - -// WrapAsPacketConn adapts a net.Conn to net.PacketConn. -func WrapAsPacketConn(c net.Conn) net.PacketConn { - return &connToPacketConn{Conn: c} -} - -func (c *connToPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { - n, err := c.Conn.Read(b) - return n, c.Conn.RemoteAddr(), err -} - -func (c *connToPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { - return c.Conn.Write(b) -} diff --git a/tools/go_sfu/network_sim.go b/tools/go_sfu/network_sim.go deleted file mode 100644 index b1b597dd05..0000000000 --- a/tools/go_sfu/network_sim.go +++ /dev/null @@ -1,128 +0,0 @@ -package main - -import ( - "math/rand" - "sync" - "time" -) - -// NetworkSimulator models a uni-directional network pipe with delay, jitter, -// packet loss, and bandwidth cap (token bucket). -type NetworkSimulator struct { - mu sync.Mutex - delayMs int - jitterMs int - dropRate float64 - bandwidthBps int64 - - // Token bucket for bandwidth cap. - tokens float64 // available tokens (bits) - maxTokens float64 // max tokens = 200ms worth of bandwidth - lastRefill time.Time - rng *rand.Rand - - closed bool -} - -// NewNetworkSimulator creates a simulator with no simulation (passthrough). -func NewNetworkSimulator() *NetworkSimulator { - return &NetworkSimulator{ - lastRefill: time.Now(), - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - } -} - -// SetParams reconfigures the simulator at runtime. Thread-safe. -func (ns *NetworkSimulator) SetParams(delayMs, jitterMs int, dropRate float64, bandwidthBps int64) { - ns.mu.Lock() - defer ns.mu.Unlock() - ns.delayMs = delayMs - ns.jitterMs = jitterMs - ns.dropRate = dropRate - ns.bandwidthBps = bandwidthBps - if bandwidthBps > 0 { - ns.maxTokens = float64(bandwidthBps) * 0.2 // 200ms buffer - if ns.tokens > ns.maxTokens { - ns.tokens = ns.maxTokens - } - } else { - ns.maxTokens = 0 - ns.tokens = 0 - } -} - -// Send processes a packet through the simulator. deliverFn is called -// (possibly asynchronously) after simulation. The packet bytes are copied -// if delivery is deferred. -func (ns *NetworkSimulator) Send(pkt []byte, deliverFn func([]byte)) { - ns.mu.Lock() - if ns.closed { - ns.mu.Unlock() - return - } - - // Drop check. - if ns.dropRate > 0 && ns.rng.Float64() < ns.dropRate { - ns.mu.Unlock() - return - } - - // Bandwidth cap: token bucket. - if ns.bandwidthBps > 0 { - ns.refillTokens() - cost := float64(len(pkt)) * 8 - if ns.tokens < cost { - // Queue full / no tokens — tail drop. - ns.mu.Unlock() - return - } - ns.tokens -= cost - } - - // Calculate delay. - delayMs := ns.delayMs - if ns.jitterMs > 0 { - delayMs += ns.rng.Intn(2*ns.jitterMs+1) - ns.jitterMs - if delayMs < 0 { - delayMs = 0 - } - } - ns.mu.Unlock() - - if delayMs == 0 { - deliverFn(pkt) - return - } - - // Copy packet for deferred delivery. - pktCopy := make([]byte, len(pkt)) - copy(pktCopy, pkt) - time.AfterFunc(time.Duration(delayMs)*time.Millisecond, func() { - deliverFn(pktCopy) - }) -} - -// Close stops the simulator. Pending delayed packets may still fire. -func (ns *NetworkSimulator) Close() { - ns.mu.Lock() - ns.closed = true - ns.mu.Unlock() -} - -// refillTokens adds tokens based on elapsed time. Must be called with mu held. -func (ns *NetworkSimulator) refillTokens() { - now := time.Now() - elapsed := now.Sub(ns.lastRefill).Seconds() - ns.lastRefill = now - ns.tokens += float64(ns.bandwidthBps) * elapsed - if ns.tokens > ns.maxTokens { - ns.tokens = ns.maxTokens - } -} - -// IsPassthrough returns true if no simulation is configured. -func (ns *NetworkSimulator) IsPassthrough() bool { - ns.mu.Lock() - defer ns.mu.Unlock() - return ns.delayMs == 0 && ns.jitterMs == 0 && ns.dropRate == 0 && ns.bandwidthBps == 0 -} diff --git a/tools/go_sfu/participant.go b/tools/go_sfu/participant.go deleted file mode 100644 index 3dd3a77bc4..0000000000 --- a/tools/go_sfu/participant.go +++ /dev/null @@ -1,637 +0,0 @@ -package main - -import ( - "context" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "fmt" - "math/big" - "net" - "sync" - "time" - - "github.com/pion/datachannel" - "github.com/pion/dtls/v3" - "github.com/pion/ice/v4" - "github.com/pion/logging" - "github.com/pion/rtcp" - "github.com/pion/sctp" - "github.com/pion/srtp/v3" -) - -// ParticipantConfig holds the client's join parameters extracted from the join payload. -type ParticipantConfig struct { - AudioSSRC uint32 - Ufrag string - Pwd string - Fingerprint string // SHA-256, colon-separated uppercase hex (e.g., "AB:CD:EF:...") -} - -// Participant holds the per-participant transport stack: ICE → DTLS → SRTP + SCTP/DataChannel. -type Participant struct { - ID int - AudioSSRC uint32 - - iceAgent *ice.Agent - iceConn *ice.Conn - - demux *PacketDemux - dtlsConn *dtls.Conn - - srtpSession *srtp.SessionSRTP - srtpWriter *srtp.WriteStreamSRTP - srtpProfile srtp.ProtectionProfile - srtpKeys srtp.SessionKeys // saved for creating SRTCP contexts - - // Separate SRTCP contexts for manual RTCP decrypt/encrypt. - // These are independent from the SessionSRTP used for RTP. - srtcpRemoteCtx *srtp.Context // decrypt SRTCP received from this participant - srtcpLocalCtx *srtp.Context // encrypt SRTCP sent to this participant - srtcpMu sync.Mutex // protects srtcpLocalCtx (single-writer) - - sctpAssoc *sctp.Association - dataChannel *datachannel.DataChannel - - tlsCert tls.Certificate - fingerprint string // SHA-256, colon-separated uppercase hex - localUfrag string - localPwd string - - loggerFactory logging.LoggerFactory - log logging.LeveledLogger - - // Video layer selection: receiver requests which layer to receive from each sender. - videoLayerMu sync.RWMutex - requestedLayers map[int]int // senderID -> layer index - - // Bandwidth estimation from REMB. - bwEstimator *BandwidthEstimator - - // Selected layers: what the SFU actually forwards (set by LayerSelector). - selectedLayerMu sync.RWMutex - selectedLayers map[int]int // senderID -> layer index - onColibriMessage func(participantID int, msg string) // set before Connect(), read from acceptDataChannel goroutine - - // RTCP feedback callback: called when PLI or FIR is received from this participant. - // mediaSSRC is the SSRC the receiver wants a keyframe for. - onRTCPFeedback func(participantID int, mediaSSRC uint32, isFIR bool) - - // Network simulation (delay/jitter/loss/bandwidth cap per direction). - ingressSim *NetworkSimulator - egressSim *NetworkSimulator - - closed chan struct{} - once sync.Once -} - -// NewParticipant creates a new Participant with an ICE agent and self-signed certificate. -// It does NOT start ICE gathering or connection — call GatherCandidates() and Connect() for that. -func NewParticipant(id int, config ParticipantConfig, loggerFactory logging.LoggerFactory) (*Participant, error) { - log := loggerFactory.NewLogger(fmt.Sprintf("participant-%d", id)) - - // Generate self-signed ECDSA P-256 certificate. - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("generate ECDSA key: %w", err) - } - - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - } - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) - if err != nil { - return nil, fmt.Errorf("create certificate: %w", err) - } - - tlsCert := tls.Certificate{ - Certificate: [][]byte{certDER}, - PrivateKey: privKey, - } - - // Compute SHA-256 fingerprint of the DER certificate. - hash := sha256.Sum256(certDER) - fingerprint := formatFingerprint(hash[:]) - - // Create ICE agent — UDP, host candidates, ICE-lite. - // The tgcalls GroupNetworkManager hardcodes ICEROLE_CONTROLLED for the client, - // so the SFU must be the controlling side (use Dial, not Accept). - // ICE-lite: the SFU passively accepts incoming connectivity checks. - // No remote candidates needed: when the client's STUN binding requests arrive, - // pion creates peer-reflexive candidates automatically. - agent, err := ice.NewAgent(&ice.AgentConfig{ - NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4}, - CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost}, - Lite: true, - IncludeLoopback: true, - IPFilter: func(ip net.IP) bool { - return true // accept all interfaces, including loopback - }, - LoggerFactory: loggerFactory, - }) - if err != nil { - return nil, fmt.Errorf("create ICE agent: %w", err) - } - - localUfrag, localPwd, err := agent.GetLocalUserCredentials() - if err != nil { - _ = agent.Close() - return nil, fmt.Errorf("get local credentials: %w", err) - } - - log.Infof("Created participant %d (SSRC=%d, ufrag=%s, fingerprint=%s)", id, config.AudioSSRC, localUfrag, fingerprint) - - return &Participant{ - ID: id, - AudioSSRC: config.AudioSSRC, - iceAgent: agent, - tlsCert: tlsCert, - fingerprint: fingerprint, - localUfrag: localUfrag, - localPwd: localPwd, - loggerFactory: loggerFactory, - log: log, - requestedLayers: make(map[int]int), - bwEstimator: &BandwidthEstimator{}, - selectedLayers: make(map[int]int), - ingressSim: NewNetworkSimulator(), - egressSim: NewNetworkSimulator(), - closed: make(chan struct{}), - }, nil -} - -// Fingerprint returns the SHA-256 fingerprint of the participant's DTLS certificate. -func (p *Participant) Fingerprint() string { - return p.fingerprint -} - -// LocalUfrag returns the local ICE username fragment. -func (p *Participant) LocalUfrag() string { - return p.localUfrag -} - -// LocalPwd returns the local ICE password. -func (p *Participant) LocalPwd() string { - return p.localPwd -} - -// GatherCandidates triggers ICE gathering and waits for completion. -// Returns the gathered ICE candidates. -func (p *Participant) GatherCandidates() ([]ice.Candidate, error) { - var ( - candidates []ice.Candidate - mu sync.Mutex - done = make(chan struct{}) - ) - - if err := p.iceAgent.OnCandidate(func(c ice.Candidate) { - if c == nil { - // nil candidate signals gathering complete. - close(done) - return - } - mu.Lock() - candidates = append(candidates, c) - mu.Unlock() - }); err != nil { - return nil, fmt.Errorf("set OnCandidate: %w", err) - } - - if err := p.iceAgent.GatherCandidates(); err != nil { - return nil, fmt.Errorf("gather candidates: %w", err) - } - - <-done - - mu.Lock() - defer mu.Unlock() - p.log.Infof("Gathered %d ICE candidates", len(candidates)) - return candidates, nil -} - -// Connect establishes the full transport stack: ICE → DTLS → SRTP + SCTP. -// The SFU is DTLS client (active). tgcalls GroupNetworkManager hardcodes SSL_SERVER. -// -// iceControlling selects the ICE role: -// - true (Dial): SFU is controlling. Required for tgcalls GroupNetworkManager which -// hardcodes ICEROLE_CONTROLLED (non-standard). -// - false (Accept): SFU is controlled (standard for ICE-lite). Required for PeerConnection -// clients that follow RFC 8445 (full agent = controlling when remote is ice-lite). -func (p *Participant) Connect(ctx context.Context, remoteUfrag, remotePwd string, iceControlling bool) error { - // 1. ICE connection. - var iceConn *ice.Conn - var err error - if iceControlling { - iceConn, err = p.iceAgent.Dial(ctx, remoteUfrag, remotePwd) - } else { - iceConn, err = p.iceAgent.Accept(ctx, remoteUfrag, remotePwd) - } - if err != nil { - return fmt.Errorf("ICE dial: %w", err) - } - p.iceConn = iceConn - p.log.Infof("ICE connected") - - // 2. Demux: split DTLS and SRTP traffic. - p.demux = NewPacketDemux(iceConn, fmt.Sprintf("p%d", p.ID)) - - // 3. DTLS: client-side handshake over the DTLS endpoint. - // tgcalls GroupNetworkManager hardcodes SetDtlsRole(SSL_SERVER), so the SFU must be the DTLS client. - dtlsEndpoint := p.demux.DTLSEndpoint() - remoteAddr := dtlsEndpoint.RemoteAddr() - packetConn := WrapAsPacketConn(dtlsEndpoint) - - dtlsConn, err := dtls.Client(packetConn, remoteAddr, &dtls.Config{ - Certificates: []tls.Certificate{p.tlsCert}, - // Offer GCM profiles matching tgcalls GroupNetworkManager::getDefaulCryptoOptions() - // which enables enable_gcm_crypto_suites=true and disables AES-128-CM-SHA1-80. - SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{ - dtls.SRTP_AEAD_AES_256_GCM, - dtls.SRTP_AEAD_AES_128_GCM, - }, - ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, - InsecureSkipVerify: true, // tgcalls verifies fingerprint out-of-band; we skip TLS chain verification - LoggerFactory: p.loggerFactory, - }) - if err != nil { - p.demux.Close() - return fmt.Errorf("DTLS create: %w", err) - } - p.dtlsConn = dtlsConn - - // dtls.Client() is lazy; explicitly run the handshake before accessing ConnectionState. - if err := dtlsConn.HandshakeContext(ctx); err != nil { - p.demux.Close() - return fmt.Errorf("DTLS handshake: %w", err) - } - p.log.Infof("DTLS connected") - - // 4. Extract SRTP keying material from DTLS. - state, ok := dtlsConn.ConnectionState() - if !ok { - return fmt.Errorf("DTLS connection state not available") - } - - // Map the negotiated DTLS-SRTP protection profile to a pion/srtp ProtectionProfile. - negotiatedProfile, profileOk := dtlsConn.SelectedSRTPProtectionProfile() - if !profileOk { - p.demux.Close() - return fmt.Errorf("no SRTP protection profile negotiated") - } - var srtpProfile srtp.ProtectionProfile - switch negotiatedProfile { - case dtls.SRTP_AEAD_AES_256_GCM: - srtpProfile = srtp.ProtectionProfileAeadAes256Gcm - case dtls.SRTP_AEAD_AES_128_GCM: - srtpProfile = srtp.ProtectionProfileAeadAes128Gcm - case dtls.SRTP_AES128_CM_HMAC_SHA1_80: - srtpProfile = srtp.ProtectionProfileAes128CmHmacSha1_80 - case dtls.SRTP_AES128_CM_HMAC_SHA1_32: - srtpProfile = srtp.ProtectionProfileAes128CmHmacSha1_32 - default: - p.demux.Close() - return fmt.Errorf("unsupported SRTP protection profile: 0x%04x", negotiatedProfile) - } - p.log.Infof("Negotiated SRTP profile: 0x%04x", negotiatedProfile) - - srtpConfig := &srtp.Config{ - Profile: srtpProfile, - } - // SFU is DTLS client → isClient=true - if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, true); err != nil { - return fmt.Errorf("extract SRTP keys: %w", err) - } - - // Save keys and profile for creating SRTCP contexts. - p.srtpProfile = srtpProfile - p.srtpKeys = srtpConfig.Keys - - // 5. SRTP session over the SRTP endpoint (RTP only — RTCP is handled separately). - srtpEndpoint := p.demux.SRTPEndpoint() - srtpSession, err := srtp.NewSessionSRTP(srtpEndpoint, srtpConfig) - if err != nil { - return fmt.Errorf("create SRTP session: %w", err) - } - p.srtpSession = srtpSession - - srtpWriter, err := srtpSession.OpenWriteStream() - if err != nil { - return fmt.Errorf("open SRTP write stream: %w", err) - } - p.srtpWriter = srtpWriter - p.log.Infof("SRTP session established") - - // 5b. Create separate SRTCP contexts for manual RTCP handling. - // Remote context: decrypt SRTCP received from this participant (their local = our remote). - p.srtcpRemoteCtx, err = srtp.CreateContext( - p.srtpKeys.RemoteMasterKey, p.srtpKeys.RemoteMasterSalt, p.srtpProfile, - ) - if err != nil { - return fmt.Errorf("create SRTCP remote context: %w", err) - } - // Local context: encrypt SRTCP we send to this participant (our local keys). - p.srtcpLocalCtx, err = srtp.CreateContext( - p.srtpKeys.LocalMasterKey, p.srtpKeys.LocalMasterSalt, p.srtpProfile, - ) - if err != nil { - return fmt.Errorf("create SRTCP local context: %w", err) - } - p.log.Infof("SRTCP contexts created") - - // 5c. Start RTCP read loop. - go p.readRTCPLoop() - - // 6. SCTP association over DTLS. - sctpAssoc, err := sctp.Server(sctp.Config{ - NetConn: dtlsConn, - LoggerFactory: p.loggerFactory, - }) - if err != nil { - return fmt.Errorf("create SCTP association: %w", err) - } - p.sctpAssoc = sctpAssoc - p.log.Infof("SCTP association established") - - // 7. Start goroutine to accept data channels. - go p.acceptDataChannel() - - return nil -} - -// acceptDataChannel waits for the client to open a data channel and reads Colibri messages. -func (p *Participant) acceptDataChannel() { - dc, err := datachannel.Accept(p.sctpAssoc, &datachannel.Config{ - LoggerFactory: p.loggerFactory, - }) - if err != nil { - select { - case <-p.closed: - return // Expected during shutdown. - default: - p.log.Warnf("Accept data channel: %v", err) - return - } - } - p.dataChannel = dc - p.log.Infof("Data channel accepted") - - buf := make([]byte, 4096) - for { - n, isString, err := dc.ReadDataChannel(buf) - if err != nil { - select { - case <-p.closed: - return - default: - p.log.Debugf("Data channel read error: %v", err) - return - } - } - if isString { - msg := string(buf[:n]) - p.log.Debugf("Colibri message: %s", msg) - if p.onColibriMessage != nil { - p.onColibriMessage(p.ID, msg) - } - } else { - p.log.Debugf("Data channel binary message (%d bytes)", n) - } - } -} - -// SetColibriCallback sets the callback for incoming Colibri data channel messages. -func (p *Participant) SetColibriCallback(cb func(participantID int, msg string)) { - p.onColibriMessage = cb -} - -// SetRTCPFeedbackCallback sets the callback for PLI/FIR RTCP feedback from this participant. -func (p *Participant) SetRTCPFeedbackCallback(cb func(participantID int, mediaSSRC uint32, isFIR bool)) { - p.onRTCPFeedback = cb -} - -// readRTCPLoop reads encrypted SRTCP packets from the demux RTCP channel, -// decrypts them, parses for PLI/FIR, and invokes the feedback callback. -func (p *Participant) readRTCPLoop() { - rtcpCh := p.demux.RTCPChannel() - decryptBuf := make([]byte, 8192) - pktCount := 0 - - for { - select { - case <-p.closed: - return - case encrypted, ok := <-rtcpCh: - if !ok { - return - } - - // Decrypt SRTCP. - decrypted, err := p.srtcpRemoteCtx.DecryptRTCP(decryptBuf[:0], encrypted, nil) - if err != nil { - pktCount++ - if pktCount <= 5 { - p.log.Debugf("SRTCP decrypt error: %v", err) - } - continue - } - - // Parse RTCP compound packet. - packets, err := rtcp.Unmarshal(decrypted) - if err != nil { - p.log.Debugf("RTCP unmarshal error: %v", err) - continue - } - - for _, pkt := range packets { - switch fb := pkt.(type) { - case *rtcp.PictureLossIndication: - p.log.Infof("Received PLI from participant %d for MediaSSRC=%d", p.ID, fb.MediaSSRC) - if p.onRTCPFeedback != nil { - p.onRTCPFeedback(p.ID, fb.MediaSSRC, false) - } - case *rtcp.FullIntraRequest: - for _, entry := range fb.FIR { - p.log.Infof("Received FIR from participant %d for SSRC=%d", p.ID, entry.SSRC) - if p.onRTCPFeedback != nil { - p.onRTCPFeedback(p.ID, entry.SSRC, true) - } - } - case *rtcp.ReceiverEstimatedMaximumBitrate: - bps := float64(fb.Bitrate) - p.bwEstimator.OnREMB(bps) - p.log.Debugf("REMB from participant %d: %.0f bps (smoothed=%.0f, effective=%.0f)", - p.ID, bps, p.bwEstimator.SmoothedBps(), p.bwEstimator.EffectiveBps()) - } - } - } - } -} - -// WriteRTCP sends a plaintext RTCP packet to this participant, encrypting it with -// the local SRTCP context and writing directly to the ICE connection. -func (p *Participant) WriteRTCP(data []byte) error { - if p.srtcpLocalCtx == nil || p.iceConn == nil { - return fmt.Errorf("SRTCP context or ICE conn not established") - } - if p.egressSim.IsPassthrough() { - return p.writeRTCPDirect(data) - } - p.egressSim.Send(data, func(delayed []byte) { - p.writeRTCPDirect(delayed) - }) - return nil -} - -func (p *Participant) writeRTCPDirect(data []byte) error { - p.srtcpMu.Lock() - encrypted, err := p.srtcpLocalCtx.EncryptRTCP(nil, data, nil) - p.srtcpMu.Unlock() - if err != nil { - return fmt.Errorf("encrypt SRTCP: %w", err) - } - _, err = p.iceConn.Write(encrypted) - return err -} - -// SetRequestedLayer sets the video layer this receiver wants from a given sender. -func (p *Participant) SetRequestedLayer(senderID int, layer int) { - p.videoLayerMu.Lock() - p.requestedLayers[senderID] = layer - p.videoLayerMu.Unlock() -} - -// GetRequestedLayer returns the video layer this receiver wants from a given sender. -// Returns -1 if no layer is requested (meaning: don't forward video from this sender). -func (p *Participant) GetRequestedLayer(senderID int) int { - p.videoLayerMu.RLock() - defer p.videoLayerMu.RUnlock() - if layer, ok := p.requestedLayers[senderID]; ok { - return layer - } - return -1 -} - -// SetSelectedLayer sets the video layer the SFU actually forwards from a given sender to this receiver. -func (p *Participant) SetSelectedLayer(senderID int, layer int) { - p.selectedLayerMu.Lock() - p.selectedLayers[senderID] = layer - p.selectedLayerMu.Unlock() -} - -// GetSelectedLayer returns the video layer the SFU forwards from a given sender to this receiver. -// Returns -1 if no layer is selected (don't forward). -func (p *Participant) GetSelectedLayer(senderID int) int { - p.selectedLayerMu.RLock() - defer p.selectedLayerMu.RUnlock() - if layer, ok := p.selectedLayers[senderID]; ok { - return layer - } - return -1 -} - -// SendText sends a UTF-8 string message over the data channel. -// Returns an error if the data channel is not yet established. -func (p *Participant) SendText(msg string) error { - dc := p.dataChannel - if dc == nil { - return fmt.Errorf("data channel not established") - } - _, err := dc.WriteDataChannel([]byte(msg), true) - return err -} - -// WriteRTP sends an encrypted RTP packet to this participant via the SRTP write stream. -func (p *Participant) WriteRTP(pkt []byte) (int, error) { - if p.srtpWriter == nil { - return 0, fmt.Errorf("SRTP session not established") - } - if p.egressSim.IsPassthrough() { - return p.srtpWriter.Write(pkt) - } - var n int - var writeErr error - p.egressSim.Send(pkt, func(delayed []byte) { - n, writeErr = p.srtpWriter.Write(delayed) - }) - return n, writeErr -} - -// AcceptStream blocks until a new SRTP read stream appears (new SSRC from client). -// Returns the read stream and its SSRC. -func (p *Participant) AcceptStream() (*srtp.ReadStreamSRTP, uint32, error) { - if p.srtpSession == nil { - return nil, 0, fmt.Errorf("SRTP session not established") - } - return p.srtpSession.AcceptStream() -} - -// Close tears down all transport layers in order. -func (p *Participant) Close() error { - var firstErr error - p.once.Do(func() { - close(p.closed) - - if p.ingressSim != nil { - p.ingressSim.Close() - } - if p.egressSim != nil { - p.egressSim.Close() - } - - if p.dataChannel != nil { - if err := p.dataChannel.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.sctpAssoc != nil { - if err := p.sctpAssoc.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.srtpSession != nil { - if err := p.srtpSession.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.dtlsConn != nil { - if err := p.dtlsConn.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.demux != nil { - if err := p.demux.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.iceConn != nil { - if err := p.iceConn.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - if p.iceAgent != nil { - if err := p.iceAgent.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - - p.log.Infof("Participant %d closed", p.ID) - }) - return firstErr -} - -// formatFingerprint converts a hash byte slice to colon-separated uppercase hex. -func formatFingerprint(hash []byte) string { - result := make([]byte, 0, len(hash)*3-1) - for i, b := range hash { - if i > 0 { - result = append(result, ':') - } - result = append(result, fmt.Sprintf("%02X", b)...) - } - return string(result) -} diff --git a/tools/go_sfu/sfu.go b/tools/go_sfu/sfu.go deleted file mode 100644 index dd5ec68857..0000000000 --- a/tools/go_sfu/sfu.go +++ /dev/null @@ -1,1240 +0,0 @@ -package main - -/* -#include -*/ -import "C" -import ( - "context" - "encoding/json" - "fmt" - "sync" - "sync/atomic" - "time" - "unsafe" - - "github.com/pion/ice/v4" - "github.com/pion/logging" - "github.com/pion/rtcp" -) - -// --- JSON types for tgcalls join protocol --- - -type joinPayload struct { - SSRC int32 `json:"ssrc"` // signed in JSON, cast to uint32 - Ufrag string `json:"ufrag"` - Pwd string `json:"pwd"` - Fingerprints []fingerprintJSON `json:"fingerprints"` - SSRCGroups []ssrcGroupJSON `json:"ssrc-groups"` -} - -type ssrcGroupJSON struct { - Semantics string `json:"semantics"` // "SIM" or "FID" - Sources []int32 `json:"sources"` // tgcalls serializes as "sources", not "ssrcs" -} - -// ssrcInfo identifies the owner and type of an SSRC in the registry. -type ssrcInfo struct { - participantID int - kind string // "audio", "video", "video-rtx" - layer int // -1 for audio, 0/1/2 for video simulcast layers -} - -// SimulcastLayer holds the primary and RTX SSRCs for one simulcast layer. -type SimulcastLayer struct { - SSRC uint32 - FidSSRC uint32 -} - -type fingerprintJSON struct { - Hash string `json:"hash"` - Fingerprint string `json:"fingerprint"` - Setup string `json:"setup"` -} - -type joinResponse struct { - Transport transportJSON `json:"transport"` - Video *videoResponseJSON `json:"video,omitempty"` -} - -type videoResponseJSON struct { - Endpoint string `json:"endpoint"` - ServerSSRCs []videoServerSSRC `json:"server_ssrcs,omitempty"` - PayloadTypes []videoPayloadTypeJSON `json:"payload-types"` - RTPHdrexts []rtpHdrextJSON `json:"rtp-hdrexts"` -} - -type videoServerSSRC struct { - SSRC int32 `json:"ssrc"` - Groups []ssrcGroupJSON `json:"ssrc-groups,omitempty"` -} - -type videoPayloadTypeJSON struct { - ID int `json:"id"` - Name string `json:"name"` - Clockrate int `json:"clockrate"` - Channels int `json:"channels,omitempty"` - Parameters map[string]string `json:"parameters,omitempty"` - Feedback []rtcpFeedbackJSON `json:"rtcp-fbs,omitempty"` -} - -type rtcpFeedbackJSON struct { - Type string `json:"type"` - Subtype string `json:"subtype,omitempty"` -} - -type rtpHdrextJSON struct { - ID int `json:"id"` - URI string `json:"uri"` -} - -type transportJSON struct { - Ufrag string `json:"ufrag"` - Pwd string `json:"pwd"` - Fingerprints []fingerprintJSON `json:"fingerprints"` - Candidates []candidateJSON `json:"candidates"` -} - -type candidateJSON struct { - Port string `json:"port"` - Protocol string `json:"protocol"` - Network string `json:"network"` - Generation string `json:"generation"` - ID string `json:"id"` - Component string `json:"component"` - Foundation string `json:"foundation"` - Priority string `json:"priority"` - IP string `json:"ip"` - Type string `json:"type"` -} - -// --- SFU --- - -type SFU struct { - mu sync.RWMutex - participants map[int]*Participant - ssrcRegistry map[uint32]ssrcInfo - videoSSRCs map[int][]SimulcastLayer // participantID -> simulcast layers - rtxBuffers map[int]*RtxRingBuffer // senderID -> RTX ring buffer - layerSelectors map[[2]int]*LayerSelector // [receiverID, senderID] -> selector - maxActiveLayer map[int]int // senderID -> highest layer with traffic - twccGenerators map[int]*TransportCCGenerator // senderID -> transport-cc generator - loggerFactory logging.LoggerFactory - log logging.LeveledLogger - ctx context.Context - cancel context.CancelFunc -} - -func NewSFU() *SFU { - lf := logging.NewDefaultLoggerFactory() - lf.DefaultLogLevel = logging.LogLevelDebug - ctx, cancel := context.WithCancel(context.Background()) - return &SFU{ - participants: make(map[int]*Participant), - ssrcRegistry: make(map[uint32]ssrcInfo), - videoSSRCs: make(map[int][]SimulcastLayer), - rtxBuffers: make(map[int]*RtxRingBuffer), - layerSelectors: make(map[[2]int]*LayerSelector), - maxActiveLayer: make(map[int]int), - twccGenerators: make(map[int]*TransportCCGenerator), - loggerFactory: lf, - log: lf.NewLogger("sfu"), - ctx: ctx, - cancel: cancel, - } -} - -// Join processes a participant's join payload and returns the SFU's join response JSON. -// iceControlling: true for CustomImpl clients (which hardcode CONTROLLED role), -// false for PeerConnection clients (standard ICE: full agent is controlling when remote is ice-lite). -func (s *SFU) Join(participantID int, joinPayloadJSON string, iceControlling bool) (string, error) { - // 1. Parse join payload. - var payload joinPayload - if err := json.Unmarshal([]byte(joinPayloadJSON), &payload); err != nil { - return "", fmt.Errorf("parse join payload: %w", err) - } - - // 2. Extract audio SSRC (signed int32 -> uint32). - audioSSRC := uint32(payload.SSRC) - - // 3. Extract fingerprint from payload (use first sha-256 fingerprint). - var remoteFingerprint string - for _, fp := range payload.Fingerprints { - if fp.Hash == "sha-256" { - remoteFingerprint = fp.Fingerprint - break - } - } - if remoteFingerprint == "" && len(payload.Fingerprints) > 0 { - remoteFingerprint = payload.Fingerprints[0].Fingerprint - } - - // 4. Parse video ssrc-groups into SimulcastLayers. - var simSSRCs []uint32 // SIM group: primary SSRCs per layer - fidMap := make(map[uint32]uint32) // primary SSRC -> RTX SSRC - for _, g := range payload.SSRCGroups { - switch g.Semantics { - case "SIM": - for _, v := range g.Sources { - simSSRCs = append(simSSRCs, uint32(v)) - } - case "FID": - if len(g.Sources) == 2 { - fidMap[uint32(g.Sources[0])] = uint32(g.Sources[1]) - } - } - } - - var videoLayers []SimulcastLayer - for _, primary := range simSSRCs { - layer := SimulcastLayer{SSRC: primary} - if rtx, ok := fidMap[primary]; ok { - layer.FidSSRC = rtx - } - videoLayers = append(videoLayers, layer) - } - - // 5. Create participant config. - config := ParticipantConfig{ - AudioSSRC: audioSSRC, - Ufrag: payload.Ufrag, - Pwd: payload.Pwd, - Fingerprint: remoteFingerprint, - } - - // 6. Create participant. - p, err := NewParticipant(participantID, config, s.loggerFactory) - if err != nil { - return "", fmt.Errorf("create participant: %w", err) - } - - // 7. Wire Colibri callback for video constraint messages. - p.SetColibriCallback(s.handleColibriMessage) - - // 7b. Wire RTCP feedback callback for PLI/FIR forwarding. - p.SetRTCPFeedbackCallback(s.handleRTCPFeedback) - - // 8. Gather ICE candidates. - candidates, err := p.GatherCandidates() - if err != nil { - p.Close() - return "", fmt.Errorf("gather candidates: %w", err) - } - - // 9. Register participant and all SSRCs. - s.mu.Lock() - s.participants[participantID] = p - s.ssrcRegistry[audioSSRC] = ssrcInfo{participantID: participantID, kind: "audio", layer: -1} - if len(videoLayers) > 0 { - s.videoSSRCs[participantID] = videoLayers - for i, vl := range videoLayers { - s.ssrcRegistry[vl.SSRC] = ssrcInfo{participantID: participantID, kind: "video", layer: i} - if vl.FidSSRC != 0 { - s.ssrcRegistry[vl.FidSSRC] = ssrcInfo{participantID: participantID, kind: "video-rtx", layer: i} - } - } - s.rtxBuffers[participantID] = NewRtxRingBuffer(200) - } - s.mu.Unlock() - - s.log.Infof("Registered participant %d: audio=%d, video layers=%d", participantID, audioSSRC, len(videoLayers)) - for i, vl := range videoLayers { - s.log.Infof(" video layer %d: ssrc=%d fid=%d", i, vl.SSRC, vl.FidSSRC) - } - - // 10. Build response JSON. - var candidatesJSON []candidateJSON - for _, c := range candidates { - candidatesJSON = append(candidatesJSON, iceCandidateToJSON(c)) - } - - resp := joinResponse{ - Transport: transportJSON{ - Ufrag: p.LocalUfrag(), - Pwd: p.LocalPwd(), - Fingerprints: []fingerprintJSON{ - { - Hash: "sha-256", - Fingerprint: p.Fingerprint(), - Setup: "active", // SFU is DTLS client (active); tgcalls is SSL_SERVER (passive) - }, - }, - Candidates: candidatesJSON, - }, - } - - // Add video section if participant has video SSRCs. - if len(videoLayers) > 0 { - resp.Video = &videoResponseJSON{ - Endpoint: fmt.Sprintf("%d", participantID), - PayloadTypes: []videoPayloadTypeJSON{ - { - ID: 100, - Name: "H264", - Clockrate: 90000, - Parameters: map[string]string{ - "profile-level-id": "42e01f", - "packetization-mode": "1", - }, - Feedback: []rtcpFeedbackJSON{ - {Type: "goog-remb"}, - {Type: "transport-cc"}, - {Type: "ccm", Subtype: "fir"}, - {Type: "nack"}, - {Type: "nack", Subtype: "pli"}, - }, - }, - { - ID: 101, - Name: "rtx", - Clockrate: 90000, - Parameters: map[string]string{ - "apt": "100", - }, - }, - }, - RTPHdrexts: []rtpHdrextJSON{ - {ID: 2, URI: "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"}, - {ID: 3, URI: "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"}, - {ID: 13, URI: "urn:3gpp:video-orientation"}, - }, - } - } - - respBytes, err := json.Marshal(resp) - if err != nil { - return "", fmt.Errorf("marshal response: %w", err) - } - - // 11. Start connection + RTP forwarding in background. - go func() { - if err := p.Connect(s.ctx, payload.Ufrag, payload.Pwd, iceControlling); err != nil { - s.log.Warnf("Participant %d connect failed: %v", participantID, err) - return - } - s.log.Infof("Participant %d connected, starting RTP forwarding", participantID) - - // Start transport-cc feedback generator for this participant. - twccGen := NewTransportCCGenerator(func(data []byte) { - if err := p.WriteRTCP(data); err != nil { - s.log.Debugf("TWCC feedback to participant %d failed: %v", participantID, err) - } - }) - s.mu.Lock() - s.twccGenerators[participantID] = twccGen - s.mu.Unlock() - - // Broadcast updated SSRC list to all participants (after data channel is ready). - // Small delay to let the data channel establish. - time.Sleep(500 * time.Millisecond) - s.broadcastActiveSSRCs() - s.broadcastActiveVideoSSRCs() - - s.forwardRTP(p) - }() - - return string(respBytes), nil -} - -// readParticipants returns a snapshot of the current participant map. -func (s *SFU) readParticipants() map[int]*Participant { - s.mu.RLock() - defer s.mu.RUnlock() - snap := make(map[int]*Participant, len(s.participants)) - for id, p := range s.participants { - snap[id] = p - } - return snap -} - -// forwardRTP reads RTP from a participant and forwards to all others. -// For video/video-rtx SSRCs, only forwards to receivers whose requested layer matches. -// Audio is forwarded unconditionally to all other participants. -func (s *SFU) forwardRTP(from *Participant) { - for { - stream, ssrc, err := from.AcceptStream() - if err != nil { - select { - case <-s.ctx.Done(): - return - default: - s.log.Warnf("Participant %d AcceptStream error: %v", from.ID, err) - return - } - } - - // Register SSRC if not already known (assume audio for undeclared SSRCs). - s.mu.Lock() - if _, exists := s.ssrcRegistry[ssrc]; !exists { - s.log.Warnf("Participant %d: undeclared SSRC %d, registering as audio", from.ID, ssrc) - s.ssrcRegistry[ssrc] = ssrcInfo{participantID: from.ID, kind: "audio", layer: -1} - } - info := s.ssrcRegistry[ssrc] - s.mu.Unlock() - - s.log.Infof("Participant %d: accepted stream SSRC=%d (kind=%s, layer=%d)", from.ID, ssrc, info.kind, info.layer) - - go func(streamInfo ssrcInfo) { - buf := make([]byte, 1500) - for { - n, err := stream.Read(buf) - if err != nil { - select { - case <-s.ctx.Done(): - return - default: - s.log.Debugf("Participant %d stream read error: %v", from.ID, err) - return - } - } - - pkt := make([]byte, n) - copy(pkt, buf[:n]) - - from.ingressSim.Send(pkt, func(simPkt []byte) { - // Record transport-cc arrival after ingress simulation. - twccSeq, ok := parseTWCCSeq(simPkt, 3) - if ok { - s.mu.RLock() - gen := s.twccGenerators[from.ID] - s.mu.RUnlock() - if gen != nil { - gen.RecordArrival(twccSeq) - } - } - - s.processIncomingRTP(from, simPkt, ssrc, streamInfo) - }) - } - }(info) - } -} - -// processIncomingRTP handles a single RTP packet from a participant after ingress simulation. -func (s *SFU) processIncomingRTP(from *Participant, pkt []byte, ssrc uint32, streamInfo ssrcInfo) { - if streamInfo.kind == "audio" { - // Audio: forward to all other participants unconditionally. - s.mu.RLock() - for id, p := range s.participants { - if id == from.ID { - continue - } - if _, err := p.WriteRTP(pkt); err != nil { - s.log.Debugf("WriteRTP to participant %d failed: %v", id, err) - } - } - s.mu.RUnlock() - } else { - // Video/video-rtx: forward the best available layer to each receiver. - // Track max active layer for video (not video-rtx) packets. - if streamInfo.kind == "video" { - s.mu.Lock() - rtxBuf := s.rtxBuffers[from.ID] - maxActiveIncreased := false - if cur, ok := s.maxActiveLayer[from.ID]; !ok || streamInfo.layer > cur { - s.maxActiveLayer[from.ID] = streamInfo.layer - maxActiveIncreased = true - } - newMax := s.maxActiveLayer[from.ID] - var selectorsToNotify []*LayerSelector - if maxActiveIncreased { - for key, sel := range s.layerSelectors { - if key[1] == from.ID { - selectorsToNotify = append(selectorsToNotify, sel) - } - } - } - s.mu.Unlock() - - // Notify layer selectors outside the lock to avoid deadlock. - for _, sel := range selectorsToNotify { - sel.OnMaxActiveLayerIncreased(newMax) - } - - if rtxBuf != nil && len(pkt) >= 4 { - seqNum := uint16(pkt[2])<<8 | uint16(pkt[3]) - var ts uint32 - if len(pkt) >= 8 { - ts = uint32(pkt[4])<<24 | uint32(pkt[5])<<16 | uint32(pkt[6])<<8 | uint32(pkt[7]) - } - rtxBuf.Push(pkt, seqNum, ts) - } - } - - s.mu.RLock() - maxActive, hasActive := s.maxActiveLayer[from.ID] - s.mu.RUnlock() - - snap := s.readParticipants() - for id, p := range snap { - if id == from.ID { - continue - } - // Determine effective layer: use selectedLayer if set, - // otherwise use maxActiveLayer (best available). - selectedLayer := p.GetSelectedLayer(from.ID) - requestedLayer := p.GetRequestedLayer(from.ID) - var effectiveLayer int - if selectedLayer >= 0 { - effectiveLayer = selectedLayer - } else if requestedLayer >= 0 { - // Pre-selector: forward at best available, capped by request. - effectiveLayer = requestedLayer - } else { - continue // receiver doesn't want video from this sender - } - // Clamp to what the sender actually produces. - if hasActive && effectiveLayer > maxActive { - effectiveLayer = maxActive - } - if streamInfo.layer == effectiveLayer { - fwdPkt := pkt - // If forwarding a non-base layer, rewrite the SSRC in the - // RTP header to the primary (layer 0) SSRC. The receiver's - // video sink is attached to the primary SSRC only. - if effectiveLayer > 0 && len(fwdPkt) >= 12 { - s.mu.RLock() - senderLayers := s.videoSSRCs[from.ID] - s.mu.RUnlock() - if len(senderLayers) > 0 { - primarySSRC := senderLayers[0].SSRC - var rtxSSRC uint32 - if streamInfo.kind == "video-rtx" && senderLayers[0].FidSSRC != 0 { - rtxSSRC = senderLayers[0].FidSSRC - } - fwdPkt = make([]byte, len(pkt)) - copy(fwdPkt, pkt) - targetSSRC := primarySSRC - if streamInfo.kind == "video-rtx" && rtxSSRC != 0 { - targetSSRC = rtxSSRC - } - fwdPkt[8] = byte(targetSSRC >> 24) - fwdPkt[9] = byte(targetSSRC >> 16) - fwdPkt[10] = byte(targetSSRC >> 8) - fwdPkt[11] = byte(targetSSRC) - } - } - if _, err := p.WriteRTP(fwdPkt); err != nil { - s.log.Debugf("WriteRTP video to participant %d failed: %v", id, err) - } - } - } - } -} - -// heightToLayer maps a requested video height to a simulcast layer index. -func heightToLayer(height int) int { - if height <= 0 { - return -1 - } - if height <= 90 { - return 0 - } - if height <= 180 { - return 1 - } - return 2 -} - -// handleColibriMessage processes an incoming Colibri message from a participant. -func (s *SFU) handleColibriMessage(participantID int, msg string) { - var base struct { - ColibriClass string `json:"colibriClass"` - } - if err := json.Unmarshal([]byte(msg), &base); err != nil { - s.log.Debugf("Participant %d: invalid Colibri JSON: %v", participantID, err) - return - } - - switch base.ColibriClass { - case "ReceiverVideoConstraints": - s.handleReceiverVideoConstraints(participantID, msg) - default: - s.log.Debugf("Participant %d: unhandled Colibri class: %s", participantID, base.ColibriClass) - } -} - -// handleRTCPFeedback is called when a participant sends PLI or FIR for a MediaSSRC. -// It looks up the sender of that SSRC and forwards a new PLI to them. -func (s *SFU) handleRTCPFeedback(fromID int, mediaSSRC uint32, isFIR bool) { - s.mu.RLock() - info, ok := s.ssrcRegistry[mediaSSRC] - if !ok { - s.mu.RUnlock() - s.log.Debugf("RTCP feedback from %d: unknown MediaSSRC=%d", fromID, mediaSSRC) - return - } - sender, senderOk := s.participants[info.participantID] - s.mu.RUnlock() - - if !senderOk { - s.log.Debugf("RTCP feedback from %d: sender %d not found for MediaSSRC=%d", fromID, info.participantID, mediaSSRC) - return - } - - kind := "PLI" - if isFIR { - kind = "FIR" - } - s.log.Infof("Forwarding %s to participant %d for MediaSSRC=%d (requested by %d)", kind, info.participantID, mediaSSRC, fromID) - - // Construct and send PLI to the sender (PLI is simpler and universally supported). - pli := &rtcp.PictureLossIndication{ - SenderSSRC: 0, - MediaSSRC: mediaSSRC, - } - data, err := rtcp.Marshal([]rtcp.Packet{pli}) - if err != nil { - s.log.Warnf("Failed to marshal PLI: %v", err) - return - } - - if err := sender.WriteRTCP(data); err != nil { - s.log.Debugf("Failed to send PLI to participant %d: %v", info.participantID, err) - } -} - -type receiverVideoConstraints struct { - DefaultConstraints *videoConstraint `json:"defaultConstraints"` - Constraints map[string]videoConstraint `json:"constraints"` -} - -type videoConstraint struct { - MinHeight int `json:"minHeight"` - MaxHeight int `json:"maxHeight"` -} - -type senderVideoConstraints struct { - ColibriClass string `json:"colibriClass"` - VideoConstraints senderVideoConstraint `json:"videoConstraints"` -} - -type senderVideoConstraint struct { - IdealHeight int `json:"idealHeight"` -} - -func (s *SFU) handleReceiverVideoConstraints(receiverID int, msg string) { - var rvc receiverVideoConstraints - if err := json.Unmarshal([]byte(msg), &rvc); err != nil { - s.log.Warnf("Participant %d: bad ReceiverVideoConstraints: %v", receiverID, err) - return - } - - s.mu.RLock() - receiver, ok := s.participants[receiverID] - s.mu.RUnlock() - if !ok { - return - } - - // Track which senders are affected so we can update their SenderVideoConstraints. - affectedSenders := make(map[int]bool) - - // Apply per-endpoint constraints and wire LayerSelector. - for endpointStr, constraint := range rvc.Constraints { - var senderID int - if _, err := fmt.Sscanf(endpointStr, "%d", &senderID); err != nil { - continue - } - layer := heightToLayer(constraint.MaxHeight) - receiver.SetRequestedLayer(senderID, layer) - s.ensureLayerSelector(receiverID, senderID, layer) - affectedSenders[senderID] = true - } - - // Apply default constraints to all other senders with video. - // Collect senderIDs under RLock, release, then call ensureLayerSelector (needs write lock). - if rvc.DefaultConstraints != nil { - defaultLayer := heightToLayer(rvc.DefaultConstraints.MaxHeight) - var defaultSenders []int - s.mu.RLock() - for senderID := range s.videoSSRCs { - if senderID == receiverID { - continue - } - if !affectedSenders[senderID] { - defaultSenders = append(defaultSenders, senderID) - } - } - s.mu.RUnlock() - - for _, senderID := range defaultSenders { - receiver.SetRequestedLayer(senderID, defaultLayer) - s.ensureLayerSelector(receiverID, senderID, defaultLayer) - affectedSenders[senderID] = true - } - } - - // Send SenderVideoConstraints to each affected sender. - // idealHeight = max height any receiver wants from this sender. - for senderID := range affectedSenders { - s.sendSenderVideoConstraints(senderID) - } - - // Send PLI to each sender that the receiver wants video from. - // This triggers a keyframe so the decoder can start producing frames. - for senderID := range affectedSenders { - layer := receiver.GetRequestedLayer(senderID) - if layer >= 0 { - s.mu.RLock() - layers := s.videoSSRCs[senderID] - s.mu.RUnlock() - if len(layers) > 0 { - s.handleRTCPFeedback(receiverID, layers[0].SSRC, false) - } - } - } -} - -// ensureLayerSelector creates or updates a LayerSelector for a (receiver, sender) pair. -func (s *SFU) ensureLayerSelector(receiverID, senderID, maxLayer int) { - if maxLayer < 0 { - return // no video requested from this sender - } - - key := [2]int{receiverID, senderID} - - s.mu.Lock() - existing, exists := s.layerSelectors[key] - if exists { - s.mu.Unlock() - existing.SetMaxLayer(maxLayer) - return - } - - receiver, recvOk := s.participants[receiverID] - videoLayers := s.videoSSRCs[senderID] - s.mu.Unlock() - - if !recvOk { - return - } - - initialLayer := maxLayer - if initialLayer > 2 { - initialLayer = 2 - } - - // Set selectedLayer immediately, clamped to what the sender produces. - // The forwardRTP loop will further clamp to maxActiveLayer on each packet. - s.mu.RLock() - maxActive, hasActive := s.maxActiveLayer[senderID] - s.mu.RUnlock() - layer := initialLayer - if hasActive && layer > maxActive { - layer = maxActive - } - receiver.SetSelectedLayer(senderID, layer) - - layersCopy := make([]SimulcastLayer, len(videoLayers)) - copy(layersCopy, videoLayers) - - cb := LayerSelectorCallbacks{ - GetEffectiveBW: func() float64 { - return receiver.bwEstimator.EffectiveBps() - }, - SetSelectedLayer: func(layer int) { - receiver.SetSelectedLayer(senderID, layer) - }, - SendPLI: func(ssrc uint32) { - s.handleRTCPFeedback(receiverID, ssrc, false) - }, - GetSenderVideoLayers: func() []SimulcastLayer { - return layersCopy - }, - GetRtxBuffer: func() *RtxRingBuffer { - s.mu.RLock() - defer s.mu.RUnlock() - return s.rtxBuffers[senderID] - }, - SendRtxPadding: func(rtxPayload []byte, rtxSSRC uint32, seqNum uint16, timestamp uint32) { - hdr := make([]byte, 12+len(rtxPayload)) - hdr[0] = 0x80 // V=2 - hdr[1] = 101 // PT=101 (RTX for H264) - hdr[2] = byte(seqNum >> 8) - hdr[3] = byte(seqNum) - hdr[4] = byte(timestamp >> 24) - hdr[5] = byte(timestamp >> 16) - hdr[6] = byte(timestamp >> 8) - hdr[7] = byte(timestamp) - hdr[8] = byte(rtxSSRC >> 24) - hdr[9] = byte(rtxSSRC >> 16) - hdr[10] = byte(rtxSSRC >> 8) - hdr[11] = byte(rtxSSRC) - copy(hdr[12:], rtxPayload) - if _, err := receiver.WriteRTP(hdr); err != nil { - s.log.Debugf("RTX padding to participant %d failed: %v", receiverID, err) - } - }, - Log: func(level string, format string, args ...interface{}) { - msg := fmt.Sprintf(format, args...) - if level == "INFO" { - s.log.Infof("%s", msg) - } else { - s.log.Debugf("%s", msg) - } - }, - } - - ls := NewLayerSelector(receiverID, senderID, initialLayer, maxLayer, cb) - - s.mu.Lock() - s.layerSelectors[key] = ls - s.mu.Unlock() -} - -func (s *SFU) sendSenderVideoConstraints(senderID int) { - snap := s.readParticipants() - - maxHeight := 0 - for id, p := range snap { - if id == senderID { - continue - } - layer := p.GetRequestedLayer(senderID) - var h int - switch layer { - case 0: - h = 90 - case 1: - h = 180 - case 2: - h = 720 - default: - continue - } - if h > maxHeight { - maxHeight = h - } - } - - sender, ok := snap[senderID] - if !ok { - return - } - - svc := senderVideoConstraints{ - ColibriClass: "SenderVideoConstraints", - VideoConstraints: senderVideoConstraint{IdealHeight: maxHeight}, - } - data, _ := json.Marshal(svc) - if err := sender.SendText(string(data)); err != nil { - s.log.Debugf("SendText SenderVideoConstraints to %d: %v", senderID, err) - } -} - -// QuerySSRC returns the participant ID for a given SSRC, or -1 if unknown. -func (s *SFU) QuerySSRC(ssrc uint32) int { - s.mu.RLock() - defer s.mu.RUnlock() - if info, ok := s.ssrcRegistry[ssrc]; ok { - return info.participantID - } - return -1 -} - -// QueryVideoSSRCs returns a JSON array of simulcast layers for a given participant. -// Format: [{"ssrc":N,"fidSsrc":M},...] -// Returns "[]" if the participant has no video SSRCs. -func (s *SFU) QueryVideoSSRCs(participantID int) string { - s.mu.RLock() - defer s.mu.RUnlock() - - layers, ok := s.videoSSRCs[participantID] - if !ok || len(layers) == 0 { - return "[]" - } - - type layerJSON struct { - SSRC uint32 `json:"ssrc"` - FidSSRC uint32 `json:"fidSsrc"` - } - out := make([]layerJSON, len(layers)) - for i, l := range layers { - out[i] = layerJSON{SSRC: l.SSRC, FidSSRC: l.FidSSRC} - } - data, _ := json.Marshal(out) - return string(data) -} - -// SetNetworkParams configures network simulation for a participant. -// direction: 0 = ingress (from client), 1 = egress (to client). -func (s *SFU) SetNetworkParams(participantID int, direction int, delayMs, jitterMs int, dropRate float64, bandwidthBps int64) { - s.mu.RLock() - p, ok := s.participants[participantID] - s.mu.RUnlock() - if !ok { - return - } - var sim *NetworkSimulator - if direction == 0 { - sim = p.ingressSim - } else { - sim = p.egressSim - } - if sim != nil { - sim.SetParams(delayMs, jitterMs, dropRate, bandwidthBps) - } - dirName := "ingress" - if direction == 1 { - dirName = "egress" - } - s.log.Infof("Participant %d %s: delay=%dms jitter=%dms drop=%.2f bw=%d bps", - participantID, dirName, delayMs, jitterMs, dropRate, bandwidthBps) -} - -// broadcastActiveSSRCs sends the current set of active audio SSRCs to all connected participants. -// Each participant receives a list excluding their own SSRC. -func (s *SFU) broadcastActiveSSRCs() { - s.mu.RLock() - defer s.mu.RUnlock() - - // Collect all audio SSRCs per participant. - participantSSRCs := make(map[int]uint32) // participantID -> audioSSRC - for ssrc, info := range s.ssrcRegistry { - if info.kind == "audio" { - if _, exists := participantSSRCs[info.participantID]; !exists { - participantSSRCs[info.participantID] = ssrc - } - } - } - - for id, p := range s.participants { - var ssrcs []int32 - for otherID, ssrc := range participantSSRCs { - if otherID == id { - continue - } - ssrcs = append(ssrcs, int32(ssrc)) - } - - msg := buildActiveSSRCsMessage(ssrcs) - if err := p.SendText(msg); err != nil { - s.log.Debugf("SendText to participant %d: %v", id, err) - } - } -} - -// broadcastActiveVideoSSRCs sends the current set of active video SSRCs to all connected participants. -// Each participant receives a list excluding their own video SSRCs. -func (s *SFU) broadcastActiveVideoSSRCs() { - s.mu.RLock() - defer s.mu.RUnlock() - - // Only broadcast if any participant has video. - if len(s.videoSSRCs) == 0 { - return - } - - for id, p := range s.participants { - var entries []videoSSRCEntry - for otherID, layers := range s.videoSSRCs { - if otherID == id { - continue - } - if len(layers) == 0 { - continue - } - entry := videoSSRCEntry{ - EndpointID: fmt.Sprintf("%d", otherID), - SSRC: int32(layers[0].SSRC), - } - // Build SIM group. - simGroup := ssrcGroupJSON{Semantics: "SIM"} - for _, l := range layers { - simGroup.Sources = append(simGroup.Sources, int32(l.SSRC)) - } - entry.SSRCGroups = append(entry.SSRCGroups, simGroup) - // Build FID groups. - for _, l := range layers { - if l.FidSSRC != 0 { - entry.SSRCGroups = append(entry.SSRCGroups, ssrcGroupJSON{ - Semantics: "FID", - Sources: []int32{int32(l.SSRC), int32(l.FidSSRC)}, - }) - } - } - entries = append(entries, entry) - } - - if len(entries) == 0 { - continue - } - - msg := buildActiveVideoSSRCsMessage(entries) - if err := p.SendText(msg); err != nil { - s.log.Debugf("SendText video SSRCs to participant %d: %v", id, err) - } - } -} - -type videoSSRCEntry struct { - EndpointID string `json:"endpointId"` - SSRC int32 `json:"ssrc"` - SSRCGroups []ssrcGroupJSON `json:"ssrcGroups"` -} - -func buildActiveVideoSSRCsMessage(entries []videoSSRCEntry) string { - type msg struct { - ColibriClass string `json:"colibriClass"` - SSRCs []videoSSRCEntry `json:"ssrcs"` - } - data, _ := json.Marshal(msg{ColibriClass: "ActiveVideoSsrcs", SSRCs: entries}) - return string(data) -} - -func buildActiveSSRCsMessage(ssrcs []int32) string { - buf := []byte(`{"colibriClass":"ActiveAudioSsrcs","ssrcs":[`) - for i, ssrc := range ssrcs { - if i > 0 { - buf = append(buf, ',') - } - buf = append(buf, fmt.Sprintf("%d", ssrc)...) - } - buf = append(buf, ']', '}') - return string(buf) -} - -// Leave removes a participant from the SFU, closes their transport, -// and broadcasts updated SSRC lists to remaining participants. -func (s *SFU) Leave(participantID int) error { - s.mu.Lock() - p, ok := s.participants[participantID] - if !ok { - s.mu.Unlock() - return fmt.Errorf("participant %d not found", participantID) - } - - // Remove from participants map. - delete(s.participants, participantID) - - // Remove all SSRCs owned by this participant. - for ssrc, info := range s.ssrcRegistry { - if info.participantID == participantID { - delete(s.ssrcRegistry, ssrc) - } - } - - // Remove video SSRCs. - delete(s.videoSSRCs, participantID) - - // Remove RTX buffer for this sender. - delete(s.rtxBuffers, participantID) - delete(s.maxActiveLayer, participantID) - - // Stop and remove all layer selectors involving this participant. - var toStop []*LayerSelector - for key, ls := range s.layerSelectors { - if key[0] == participantID || key[1] == participantID { - toStop = append(toStop, ls) - delete(s.layerSelectors, key) - } - } - - // Remove TWCC generator. - var twccGen *TransportCCGenerator - if gen, ok := s.twccGenerators[participantID]; ok { - twccGen = gen - delete(s.twccGenerators, participantID) - } - - s.mu.Unlock() - - // Stop layer selectors outside the lock. - for _, ls := range toStop { - ls.Stop() - } - - // Stop TWCC generator outside the lock. - if twccGen != nil { - twccGen.Stop() - } - - // Close transport (outside lock — Close can block). - if err := p.Close(); err != nil { - s.log.Warnf("Error closing participant %d: %v", participantID, err) - } - - s.log.Infof("Participant %d left", participantID) - - // Broadcast updated SSRC lists to remaining participants. - s.broadcastActiveSSRCs() - s.broadcastActiveVideoSSRCs() - - return nil -} - -// Destroy closes all participants and cancels the SFU context. -func (s *SFU) Destroy() { - s.cancel() - s.mu.Lock() - // Stop all layer selectors before closing participants. - for _, ls := range s.layerSelectors { - ls.Stop() - } - s.layerSelectors = nil - // Stop all TWCC generators. - for _, gen := range s.twccGenerators { - gen.Stop() - } - s.twccGenerators = nil - for id, p := range s.participants { - if err := p.Close(); err != nil { - s.log.Warnf("Error closing participant %d: %v", id, err) - } - } - s.participants = nil - s.ssrcRegistry = nil - s.videoSSRCs = nil - s.rtxBuffers = nil - s.maxActiveLayer = nil - s.mu.Unlock() -} - -// iceCandidateToJSON converts a pion ICE candidate to our JSON format. -func iceCandidateToJSON(c ice.Candidate) candidateJSON { - return candidateJSON{ - Port: fmt.Sprintf("%d", c.Port()), - Protocol: "udp", - Network: "0", - Generation: "0", - ID: c.ID(), - Component: fmt.Sprintf("%d", c.Component()), - Foundation: c.Foundation(), - Priority: fmt.Sprintf("%d", c.Priority()), - IP: c.Address(), - Type: "host", - } -} - -// --- Global SFU registry --- - -var ( - sfuRegistry = make(map[int]*SFU) - sfuRegistryMu sync.Mutex - sfuNextID int32 -) - -// --- CGo exports --- - -//export GoSfu_Init -func GoSfu_Init() C.int { - fmt.Println("[GoSfu] Initialized") - return 0 -} - -//export GoSfu_Create -func GoSfu_Create() C.int { - handle := int(atomic.AddInt32(&sfuNextID, 1)) - sfu := NewSFU() - sfuRegistryMu.Lock() - sfuRegistry[handle] = sfu - sfuRegistryMu.Unlock() - fmt.Printf("[GoSfu] Created SFU handle=%d\n", handle) - return C.int(handle) -} - -//export GoSfu_Destroy -func GoSfu_Destroy(handle C.int) { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - if ok { - delete(sfuRegistry, h) - } - sfuRegistryMu.Unlock() - if ok { - sfu.Destroy() - fmt.Printf("[GoSfu] Destroyed SFU handle=%d\n", h) - } -} - -//export GoSfu_Join -func GoSfu_Join(handle C.int, participantID C.int, joinPayloadJSON *C.char, iceControlling C.int) *C.char { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - sfuRegistryMu.Unlock() - if !ok { - errMsg := fmt.Sprintf(`{"error":"unknown SFU handle %d"}`, h) - return C.CString(errMsg) - } - - payload := C.GoString(joinPayloadJSON) - resp, err := sfu.Join(int(participantID), payload, iceControlling != 0) - if err != nil { - errMsg := fmt.Sprintf(`{"error":"%s"}`, err.Error()) - return C.CString(errMsg) - } - return C.CString(resp) -} - -//export GoSfu_Leave -func GoSfu_Leave(handle C.int, participantID C.int) C.int { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - sfuRegistryMu.Unlock() - if !ok { - return -1 - } - if err := sfu.Leave(int(participantID)); err != nil { - fmt.Printf("[GoSfu] Leave error: %v\n", err) - return -1 - } - return 0 -} - -//export GoSfu_QuerySsrc -func GoSfu_QuerySsrc(handle C.int, ssrc C.uint) C.int { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - sfuRegistryMu.Unlock() - if !ok { - return -1 - } - return C.int(sfu.QuerySSRC(uint32(ssrc))) -} - -//export GoSfu_QueryVideoSsrcs -func GoSfu_QueryVideoSsrcs(handle C.int, participantID C.int) *C.char { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - sfuRegistryMu.Unlock() - if !ok { - return C.CString("[]") - } - return C.CString(sfu.QueryVideoSSRCs(int(participantID))) -} - -//export GoSfu_SetNetworkParams -func GoSfu_SetNetworkParams(handle C.int, participantID C.int, direction C.int, delayMs C.int, jitterMs C.int, dropRate C.double, bandwidthBps C.long) { - h := int(handle) - sfuRegistryMu.Lock() - sfu, ok := sfuRegistry[h] - sfuRegistryMu.Unlock() - if !ok { - return - } - sfu.SetNetworkParams(int(participantID), int(direction), int(delayMs), int(jitterMs), float64(dropRate), int64(bandwidthBps)) -} - -//export GoSfu_Free -func GoSfu_Free(ptr *C.char) { - C.free(unsafe.Pointer(ptr)) -} - -//export GoSfu_Shutdown -func GoSfu_Shutdown() { - sfuRegistryMu.Lock() - for h, sfu := range sfuRegistry { - sfu.Destroy() - delete(sfuRegistry, h) - } - sfuRegistryMu.Unlock() - fmt.Println("[GoSfu] Shutdown") -} - -func main() {} diff --git a/tools/go_sfu/twcc.go b/tools/go_sfu/twcc.go deleted file mode 100644 index 2afc0487c2..0000000000 --- a/tools/go_sfu/twcc.go +++ /dev/null @@ -1,262 +0,0 @@ -package main - -import ( - "encoding/binary" - "sync" - "time" - - "github.com/pion/rtcp" -) - -// --- RTP Header Extension Parsing --- - -// parseTWCCSeq extracts the transport-wide sequence number from an RTP packet. -// extID is the header extension ID to look for (typically 3). -// Returns the sequence number and true if found, or 0 and false. -func parseTWCCSeq(pkt []byte, extID int) (uint16, bool) { - if len(pkt) < 12 { - return 0, false - } - - // Check extension bit (X) in RTP header byte 0. - if pkt[0]&0x10 == 0 { - return 0, false - } - - // Skip fixed header (12 bytes) + CSRC list. - cc := int(pkt[0] & 0x0F) - offset := 12 + cc*4 - if offset+4 > len(pkt) { - return 0, false - } - - // Check for one-byte header extension (0xBEDE magic). - if pkt[offset] != 0xBE || pkt[offset+1] != 0xDE { - return 0, false - } - - // Extension length in 32-bit words. - extLen := int(binary.BigEndian.Uint16(pkt[offset+2:])) * 4 - offset += 4 - extEnd := offset + extLen - if extEnd > len(pkt) { - return 0, false - } - - // Scan extension elements: [id:4][len:4][data...]. - for offset < extEnd { - b := pkt[offset] - if b == 0 { - // Padding byte. - offset++ - continue - } - id := int(b >> 4) - dataLen := int(b&0x0F) + 1 // len field is 0-based - offset++ - if id == extID && dataLen >= 2 && offset+2 <= extEnd { - seq := binary.BigEndian.Uint16(pkt[offset:]) - return seq, true - } - offset += dataLen - } - - return 0, false -} - -// --- Transport-CC Feedback Generator --- - -type twccArrival struct { - seq uint16 - arrivalUs int64 // microseconds since generator creation -} - -// TransportCCGenerator generates RTCP transport-cc feedback for a sender. -// It tracks packet arrivals and emits feedback every 100ms. -type TransportCCGenerator struct { - mu sync.Mutex - arrivals []twccArrival - startTime time.Time - fbCount uint8 // feedback packet counter - - // Callback to send the feedback RTCP packet. - sendFeedback func(data []byte) - - stopCh chan struct{} - done chan struct{} -} - -// NewTransportCCGenerator creates and starts a generator. -// sendFeedback is called with marshalled+encrypted RTCP data to send to the sender. -func NewTransportCCGenerator(sendFeedback func(data []byte)) *TransportCCGenerator { - g := &TransportCCGenerator{ - startTime: time.Now(), - sendFeedback: sendFeedback, - stopCh: make(chan struct{}), - done: make(chan struct{}), - } - go g.run() - return g -} - -// RecordArrival records a packet arrival. Thread-safe. -func (g *TransportCCGenerator) RecordArrival(twccSeq uint16) { - g.mu.Lock() - defer g.mu.Unlock() - arrivalUs := time.Since(g.startTime).Microseconds() - g.arrivals = append(g.arrivals, twccArrival{seq: twccSeq, arrivalUs: arrivalUs}) -} - -// Stop terminates the generator. -func (g *TransportCCGenerator) Stop() { - close(g.stopCh) - <-g.done -} - -func (g *TransportCCGenerator) run() { - defer close(g.done) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-g.stopCh: - return - case <-ticker.C: - g.emitFeedback() - } - } -} - -func (g *TransportCCGenerator) emitFeedback() { - g.mu.Lock() - if len(g.arrivals) == 0 { - g.mu.Unlock() - return - } - - // Take all arrivals. - arrivals := g.arrivals - g.arrivals = nil - g.fbCount++ - fbCount := g.fbCount - g.mu.Unlock() - - // Sort by sequence number (should already be mostly sorted). - for i := 1; i < len(arrivals); i++ { - for j := i; j > 0 && seqBefore(arrivals[j].seq, arrivals[j-1].seq); j-- { - arrivals[j], arrivals[j-1] = arrivals[j-1], arrivals[j] - } - } - - baseSeq := arrivals[0].seq - // Number of sequence numbers covered (including gaps). - lastSeq := arrivals[len(arrivals)-1].seq - packetCount := seqDiff(baseSeq, lastSeq) + 1 - - // Reference time: arrival of first packet in 64ms units. - refTimeUs := arrivals[0].arrivalUs - refTime := uint32(refTimeUs / 64000) // 64ms units, 24-bit in spec but stored as uint32 - - // Build received set for gap detection. - receivedAt := make(map[uint16]int64, len(arrivals)) - for _, a := range arrivals { - receivedAt[a.seq] = a.arrivalUs - } - - // Build packet chunks and recv deltas. - var chunks []rtcp.PacketStatusChunk - var deltas []*rtcp.RecvDelta - - // Process in runs of up to 7 (status vector chunk capacity for 2-bit symbols). - prevArrivalUs := refTimeUs - var statusList []uint16 - - seq := baseSeq - for i := 0; i < int(packetCount); i++ { - arrUs, received := receivedAt[seq] - if received { - deltaUs := arrUs - prevArrivalUs - if deltaUs >= 0 && deltaUs <= 63750 { // fits in small delta (0-255 * 250us) - statusList = append(statusList, rtcp.TypeTCCPacketReceivedSmallDelta) - deltas = append(deltas, &rtcp.RecvDelta{ - Type: rtcp.TypeTCCPacketReceivedSmallDelta, - Delta: deltaUs, - }) - } else { - statusList = append(statusList, rtcp.TypeTCCPacketReceivedLargeDelta) - deltas = append(deltas, &rtcp.RecvDelta{ - Type: rtcp.TypeTCCPacketReceivedLargeDelta, - Delta: deltaUs, - }) - } - prevArrivalUs = arrUs - } else { - statusList = append(statusList, rtcp.TypeTCCPacketNotReceived) - } - seq++ - } - - // Encode status list as status vector chunks (7 symbols per chunk with 2-bit symbols). - for i := 0; i < len(statusList); i += 7 { - end := i + 7 - if end > len(statusList) { - end = len(statusList) - } - chunk := statusList[i:end] - - // Check if all same status (use run-length). - allSame := true - for _, s := range chunk { - if s != chunk[0] { - allSame = false - break - } - } - - if allSame && len(chunk) >= 2 { - chunks = append(chunks, &rtcp.RunLengthChunk{ - Type: rtcp.TypeTCCRunLengthChunk, - PacketStatusSymbol: chunk[0], - RunLength: uint16(len(chunk)), - }) - } else { - // Status vector with 2-bit symbols. - symbolList := make([]uint16, len(chunk)) - copy(symbolList, chunk) - chunks = append(chunks, &rtcp.StatusVectorChunk{ - Type: rtcp.TypeTCCStatusVectorChunk, - SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit, - SymbolList: symbolList, - }) - } - } - - fb := &rtcp.TransportLayerCC{ - SenderSSRC: 1, - MediaSSRC: 0, - BaseSequenceNumber: baseSeq, - PacketStatusCount: packetCount, - ReferenceTime: refTime, - FbPktCount: fbCount, - PacketChunks: chunks, - RecvDeltas: deltas, - } - - data, err := rtcp.Marshal([]rtcp.Packet{fb}) - if err != nil { - return - } - - g.sendFeedback(data) -} - -// seqBefore returns true if a comes before b in the uint16 sequence space. -func seqBefore(a, b uint16) bool { - return int16(a-b) < 0 -} - -// seqDiff returns the forward distance from a to b in uint16 sequence space. -func seqDiff(a, b uint16) uint16 { - return b - a -} diff --git a/tools/tgcalls_cli/BUILD b/tools/tgcalls_cli/BUILD deleted file mode 100644 index 294db47916..0000000000 --- a/tools/tgcalls_cli/BUILD +++ /dev/null @@ -1,37 +0,0 @@ -cc_binary( - name = "tgcalls_cli", - srcs = [ - "main.cpp", - "group_mode.cpp", - "group_mode.h", - "group_participant.cpp", - "group_participant.h", - "group_churn_mode.cpp", - "group_churn_mode.h", - "fake_video_source.h", - "fake_video_source.cpp", - "fake_video_sink.h", - ], - copts = [ - "-I{}/tgcalls/tgcalls".format("submodules/TgVoipWebrtc"), - "-Ithird-party/webrtc/webrtc", - "-Ithird-party/webrtc/dependencies", - "-Ithird-party/webrtc/absl", - "-Ithird-party/libyuv", - "-DRTC_ENABLE_VP9", - "-DNDEBUG", - "-std=c++17", - "-w", - ] + select({ - "@platforms//os:linux": ["-DWEBRTC_LINUX", "-DWEBRTC_POSIX"], - "//conditions:default": ["-DWEBRTC_MAC", "-DWEBRTC_POSIX"], - }), - linkopts = select({ - "@platforms//os:linux": ["-lpthread", "-lm", "-ldl"], - "//conditions:default": ["-framework", "CoreFoundation", "-framework", "Security"], - }), - deps = [ - "//submodules/TgVoipWebrtc:tgcalls_core", - "//tools/go_sfu", - ], -) diff --git a/tools/tgcalls_cli/CLAUDE.md b/tools/tgcalls_cli/CLAUDE.md deleted file mode 100644 index b691ddf77c..0000000000 --- a/tools/tgcalls_cli/CLAUDE.md +++ /dev/null @@ -1,41 +0,0 @@ -# tgcalls CLI Test Tool - -In-process test harness for tgcalls. See the root `CLAUDE.md` for build instructions, top-level CLI usage, and the CLI options reference. - -## Supported Versions - -| Version | Implementation | Notes | -|---|---|---| -| `14.0.0` | `InstanceV2CompatImpl` | WebRTC PeerConnection + V2Impl signaling. Cross-version interop with 7.0.0–13.0.0 | -| `13.0.0` (default) | `InstanceV2Impl` | Also: 7.0.0, 8.0.0, 9.0.0, 12.0.0 | -| `11.0.0` | `InstanceV2ReferenceImpl` | Also: 10.0.0. Uses WebRTC PeerConnection | -| `5.0.0` | `InstanceImpl` (v1) | Also: 2.7.7. Legacy | - -## Architecture (P2P/Reflector) -- Two `tgcalls::Instance` objects (caller + callee) created via `Meta::Create(version, ...)` -- Signaling bridged via `SignalingBridge` with configurable drop rate and delay -- `FakeAudioDeviceModule` with `SineRecorder` (440Hz tone) and `NoOpRenderer` (audio discarded; validation via BWE) -- `FakeInterface` platform implementation (pure C++, no iOS/ObjC deps) -- Stats log validation: both caller and callee write `config.statsLogPath` with bitrate records; non-empty log with at least one non-zero BWE value is a success condition -- On failure, full tgcalls internal logs (caller + callee) are dumped to stdout via `config.logPath` - -## Architecture (Group) -- N participants using `GroupInstanceCustomImpl` and/or `GroupInstanceReferenceImpl` connect to an in-process Go SFU -- SFU uses Pion's low-level APIs (pion/ice, pion/dtls, pion/srtp, pion/sctp) — NOT PeerConnection -- ICE: lite mode, loopback-only, UDP host candidates on 127.0.0.1. SFU uses `Dial` (controlling) for CustomImpl clients and `Accept` (controlled) for PeerConnection clients -- DTLS: SFU acts as DTLS client (setup=active); GroupNetworkManager hardcodes SSL_SERVER for the tgcalls client -- SRTP: AES-256-GCM (negotiated via DTLS-SRTP; GroupNetworkManager requires GCM suites) -- SCTP: over DTLS, accepts data channel from client, reads Colibri messages, sends `ActiveAudioSsrcs` and `ActiveVideoSsrcs` notifications -- RTP forwarding: audio RTP forwarded to all others unconditionally; video RTP forwarded only to receivers that have requested video from that sender (via `ReceiverVideoConstraints`) -- SSRC tracking: SFU maintains `ssrcRegistry map[uint32]ssrcInfo` with kind (audio/video/video-rtx) and simulcast layer index, exposed via `GoSfu_QuerySsrc` and `GoSfu_QueryVideoSsrcs` CGo exports -- SSRC discovery: SFU broadcasts `ActiveAudioSsrcs` and `ActiveVideoSsrcs` over data channel when participants connect -- Video SSRC groups: parsed from join payload `"ssrc-groups"` field (SIM + FID semantics), stored per participant -- Colibri video constraints: SFU parses `ReceiverVideoConstraints` from receivers, sends `SenderVideoConstraints` back to senders with `idealHeight`, and sends proactive PLI to trigger keyframes when a receiver first requests video -- RTCP feedback: SFU demuxes SRTCP from the shared ICE transport (RFC 5761: byte[1] >= 200 && < 224), decrypts with per-participant SRTCP contexts, parses PLI/FIR, and forwards as new PLI to the sender. NACK is terminated (not forwarded). -- Audio validation: `audioLevelsUpdated` callback tracks remote audio levels; success requires every participant to receive audio from at least one other participant (remote SSRC != 0, level > 0.05). The 440Hz sine tone arrives at ~0.126 level after SFU forwarding. -- Video validation: `FakeVideoSink` (implements `rtc::VideoSinkInterface`) counts decoded frames per remote endpoint; success requires every participant to receive ≥1 frame from every other -- Video signaling flow: SFU broadcasts `ActiveVideoSsrcs` over data channel → `dataChannelMessageReceived` callback fires in the app → app calls `setRequestedVideoChannels` → CustomImpl creates `IncomingVideoChannel` / ReferenceImpl adds recvonly video transceiver → both send `ReceiverVideoConstraints` → SFU sends `SenderVideoConstraints` + proactive PLI → sender produces keyframe → receiver decodes -- `dataChannelMessageReceived` callback: added to `GroupInstanceDescriptor`, forwards all incoming Colibri data channel messages to the application. Used by the CLI test tool to react to `ActiveVideoSsrcs` and dynamically set up video channels — mirrors the real Telegram app's reactive flow -- `FakeAudioDeviceModule` with `SineRecorder` (440Hz tone) and `NoOpRenderer` — same as P2P mode -- `FakeVideoTrackSource` generates 1280x720 I420 frames at 30fps with per-participant color tint and frame counter (720p needed for 3 simulcast layers; 640x360 only allows 2 per WebRTC's `kSimulcastFormats`) -- Group mode source: `tools/tgcalls_cli/group_mode.cpp` diff --git a/tools/tgcalls_cli/fake_video_sink.h b/tools/tgcalls_cli/fake_video_sink.h deleted file mode 100644 index f54d0a5fc7..0000000000 --- a/tools/tgcalls_cli/fake_video_sink.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include "api/video/video_frame.h" -#include "api/video/video_sink_interface.h" -#include - -class FakeVideoSink : public rtc::VideoSinkInterface { -public: - void OnFrame(const webrtc::VideoFrame& frame) override { - frameCount_.fetch_add(1, std::memory_order_relaxed); - int w = frame.width(); - int h = frame.height(); - lastWidth_.store(w, std::memory_order_relaxed); - lastHeight_.store(h, std::memory_order_relaxed); - } - int lastWidth() const { return lastWidth_.load(std::memory_order_relaxed); } - int lastHeight() const { return lastHeight_.load(std::memory_order_relaxed); } - int frameCount() const { - return frameCount_.load(std::memory_order_relaxed); - } -private: - std::atomic frameCount_{0}; - std::atomic lastWidth_{0}; - std::atomic lastHeight_{0}; -}; diff --git a/tools/tgcalls_cli/fake_video_source.cpp b/tools/tgcalls_cli/fake_video_source.cpp deleted file mode 100644 index 597800303c..0000000000 --- a/tools/tgcalls_cli/fake_video_source.cpp +++ /dev/null @@ -1,149 +0,0 @@ -#include "fake_video_source.h" - -#include "api/video/video_frame.h" -#include "api/video/video_rotation.h" -#include "rtc_base/time_utils.h" - -#include - -namespace { - -constexpr int kWidth = 1280; -constexpr int kHeight = 720; -constexpr int kFps = 30; -constexpr uint8_t kBgY = 80; // dark background -constexpr uint8_t kDigitY = 235; // white digits -constexpr int kDigitW = 5; -constexpr int kDigitH = 7; -constexpr int kScale = 4; -constexpr int kDigitSpacing = 2; // pixels between digits (scaled) -constexpr int kMargin = 8; // top-left margin in pixels - -// 5x7 bitmap font for digits 0-9. Each entry is 7 rows of 5-bit patterns. -// MSB = leftmost pixel. -static const uint8_t kDigitBitmaps[10][7] = { - // 0 - {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110}, - // 1 - {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110}, - // 2 - {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111}, - // 3 - {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110}, - // 4 - {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010}, - // 5 - {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110}, - // 6 - {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110}, - // 7 - {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000}, - // 8 - {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110}, - // 9 - {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100}, -}; - -// 6 color tints cycling: red, green, blue, yellow, cyan, magenta -// UV values for each tint (in I420, U=Cb, V=Cr; neutral=128) -struct UVTint { uint8_t u; uint8_t v; }; -static const UVTint kTints[6] = { - {90, 240}, // red - {54, 34}, // green - {240, 110}, // blue - {16, 146}, // yellow - {166, 16}, // cyan - {166, 240}, // magenta -}; - -} // namespace - -FakeVideoTrackSource::FakeVideoTrackSource(int participantId) - : participantId_(participantId) { - const auto& tint = kTints[participantId % 6]; - uTint_ = tint.u; - vTint_ = tint.v; - thread_ = std::thread(&FakeVideoTrackSource::GenerateThread, this); -} - -FakeVideoTrackSource::~FakeVideoTrackSource() { - Stop(); -} - -rtc::scoped_refptr FakeVideoTrackSource::Create(int participantId) { - return rtc::scoped_refptr( - new rtc::RefCountedObject(participantId)); -} - -void FakeVideoTrackSource::Stop() { - if (running_.exchange(false)) { - if (thread_.joinable()) { - thread_.join(); - } - } -} - -void FakeVideoTrackSource::GenerateThread() { - int frameNumber = 0; - while (running_.load(std::memory_order_relaxed)) { - auto buffer = webrtc::I420Buffer::Create(kWidth, kHeight); - - // Fill Y plane with dark background - memset(buffer->MutableDataY(), kBgY, buffer->StrideY() * kHeight); - - // Fill U plane with tint - int uvHeight = (kHeight + 1) / 2; - memset(buffer->MutableDataU(), uTint_, buffer->StrideU() * uvHeight); - - // Fill V plane with tint - memset(buffer->MutableDataV(), vTint_, buffer->StrideV() * uvHeight); - - // Render frame counter digits - RenderDigits(buffer->MutableDataY(), buffer->StrideY(), frameNumber); - - auto frame = webrtc::VideoFrame::Builder() - .set_video_frame_buffer(buffer) - .set_rotation(webrtc::kVideoRotation_0) - .set_timestamp_us(rtc::TimeMicros()) - .build(); - - OnFrame(frame); - - ++frameNumber; - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / kFps)); - } -} - -void FakeVideoTrackSource::RenderDigits(uint8_t* yPlane, int strideY, int frameNumber) { - // Convert frame number to decimal digits - char numStr[16]; - snprintf(numStr, sizeof(numStr), "%d", frameNumber); - int numDigits = static_cast(strlen(numStr)); - - int xOffset = kMargin; - for (int d = 0; d < numDigits; ++d) { - int digit = numStr[d] - '0'; - const uint8_t* bitmap = kDigitBitmaps[digit]; - - for (int row = 0; row < kDigitH; ++row) { - uint8_t rowBits = bitmap[row]; - for (int col = 0; col < kDigitW; ++col) { - if (rowBits & (1 << (kDigitW - 1 - col))) { - // Fill scaled pixel block - int px = xOffset + col * kScale; - int py = kMargin + row * kScale; - for (int sy = 0; sy < kScale; ++sy) { - for (int sx = 0; sx < kScale; ++sx) { - int x = px + sx; - int y = py + sy; - if (x < kWidth && y < kHeight) { - yPlane[y * strideY + x] = kDigitY; - } - } - } - } - } - } - xOffset += kDigitW * kScale + kDigitSpacing; - } -} diff --git a/tools/tgcalls_cli/fake_video_source.h b/tools/tgcalls_cli/fake_video_source.h deleted file mode 100644 index b17bd2b6a6..0000000000 --- a/tools/tgcalls_cli/fake_video_source.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "api/video/i420_buffer.h" -#include "media/base/adapted_video_track_source.h" -#include "rtc_base/ref_counted_object.h" - -#include -#include - -// Generates 640x360 I420 frames at 30fps with per-participant color tint -// and an incrementing frame counter rendered as block digits. -class FakeVideoTrackSource : public rtc::AdaptedVideoTrackSource { -public: - static rtc::scoped_refptr Create(int participantId); - - ~FakeVideoTrackSource() override; - - void Stop(); - - // VideoTrackSourceInterface - SourceState state() const override { return kLive; } - bool remote() const override { return false; } - bool is_screencast() const override { return false; } - absl::optional needs_denoising() const override { return false; } - -protected: - explicit FakeVideoTrackSource(int participantId); - -private: - void GenerateThread(); - void RenderDigits(uint8_t* yPlane, int strideY, int frameNumber); - - int participantId_; - uint8_t uTint_; - uint8_t vTint_; - std::atomic running_{true}; - std::thread thread_; -}; diff --git a/tools/tgcalls_cli/group_churn_mode.cpp b/tools/tgcalls_cli/group_churn_mode.cpp deleted file mode 100644 index 05001f6bb8..0000000000 --- a/tools/tgcalls_cli/group_churn_mode.cpp +++ /dev/null @@ -1,205 +0,0 @@ -#include "group_churn_mode.h" -#include "group_participant.h" - -#include -#include -#include -#include - -// CGo header -#include "tools/go_sfu/go_sfu.h" - -int runGroupChurnMode( - int customParticipants, - int referenceParticipants, - int duration, - bool quiet, - bool video, - int churnCycles -) { - gGroupQuiet = quiet; - gGroupStartTime = std::chrono::steady_clock::now(); - - int baseCount = customParticipants + referenceParticipants; - if (baseCount < 2) { - fprintf(stderr, "Error: need at least 2 base participants total\n"); - return 1; - } - - groupLog("Churn", "initializing Go SFU..."); - - int rc = GoSfu_Init(); - if (rc != 0) { - fprintf(stderr, "Error: GoSfu_Init failed with %d\n", rc); - return 1; - } - - GoInt sfuHandle = GoSfu_Create(); - if (sfuHandle <= 0) { - fprintf(stderr, "Error: GoSfu_Create failed\n"); - return 1; - } - - groupLog("Churn", "SFU handle=%lld, base=%d (custom=%d, ref=%d), cycles=%d, video=%s", - (long long)sfuHandle, baseCount, customParticipants, referenceParticipants, - churnCycles, video ? "yes" : "no"); - - auto threads = tgcalls::StaticThreads::getThreads(); - - // --- Phase 1: Create base group --- - groupLog("Churn", "creating base group..."); - std::vector> baseStates; - bool anyFailed = false; - - for (int i = 0; i < baseCount; ++i) { - bool isReference = (i >= customParticipants); - auto state = createParticipant(i, isReference, sfuHandle, threads, quiet, video, &baseStates); - if (!state) { - anyFailed = true; - continue; - } - baseStates.push_back(std::move(state)); - } - - // Wait for all base participants to connect - groupLog("Churn", "waiting for base group connections..."); - auto waitStart = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(15)) { - int connectedCount = 0; - for (const auto& s : baseStates) { - if (s->wasConnected.load()) connectedCount++; - } - if (connectedCount == (int)baseStates.size()) { - groupLog("Churn", "all %d base participants connected", (int)baseStates.size()); - break; - } - groupLog("Churn", "base connected: %d/%d", connectedCount, (int)baseStates.size()); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - - // Wait for audio to flow in base group - groupLog("Churn", "waiting for base group audio..."); - waitStart = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(10)) { - int audioCount = 0; - for (const auto& s : baseStates) { - if (s->receivedAudio.load()) audioCount++; - } - if (audioCount == (int)baseStates.size()) { - groupLog("Churn", "all %d base participants receiving audio", (int)baseStates.size()); - break; - } - groupLog("Churn", "base audio: %d/%d", audioCount, (int)baseStates.size()); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - - // --- Phase 2: Churn loop --- - groupLog("Churn", "starting churn: %d cycles", churnCycles); - int nextId = baseCount; - int completedCycles = 0; - - for (int cycle = 0; cycle < churnCycles; ++cycle) { - bool isReference = (cycle % 2 == 1); - int churnId = nextId++; - - auto churner = createParticipant(churnId, isReference, sfuHandle, threads, quiet, video, &baseStates); - if (!churner) { - groupLog("Churn", "cycle %d: createParticipant failed for id=%d", cycle, churnId); - anyFailed = true; - continue; - } - - // Wait briefly for connection (up to 3s) - auto connStart = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() - connStart < std::chrono::seconds(3)) { - if (churner->wasConnected.load()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - - if (!churner->wasConnected.load()) { - groupLog("Churn", "cycle %d: churner %d did not connect (continuing anyway)", cycle, churnId); - } - - // Leave - stopParticipant(churner.get(), sfuHandle); - completedCycles++; - - if ((cycle + 1) % 10 == 0) { - groupLog("Churn", "progress: %d/%d cycles completed", cycle + 1, churnCycles); - } - } - - groupLog("Churn", "churn complete: %d/%d cycles succeeded", completedCycles, churnCycles); - - // --- Phase 3: Stabilize and validate --- - groupLog("Churn", "stabilizing for %d seconds...", duration); - std::this_thread::sleep_for(std::chrono::seconds(duration)); - - auto result = validateGroupState(baseStates, video); - - // --- Phase 4: Teardown --- - groupLog("Churn", "stopping base participants..."); - - // Stop video sources - for (auto& s : baseStates) { - if (s->videoSource) { - s->videoSource->Stop(); - } - } - - // Stop instances - std::atomic stopCount{0}; - std::mutex stopMutex; - std::condition_variable stopCv; - - for (const auto& s : baseStates) { - if (s->instance) { - int pid_local = s->id; - s->instance->stop([&stopCount, &stopMutex, &stopCv, pid_local]() { - groupLog("Churn", "base participant %d stopped", pid_local); - stopCount.fetch_add(1); - std::lock_guard lock(stopMutex); - stopCv.notify_all(); - }); - } - } - - { - std::unique_lock lock(stopMutex); - stopCv.wait_for(lock, std::chrono::seconds(5), [&] { - return stopCount.load() >= (int)baseStates.size(); - }); - } - - for (auto& s : baseStates) { - s->instance.reset(); - } - - GoSfu_Destroy(sfuHandle); - GoSfu_Shutdown(); - - // Print summary - bool success = result.success && !anyFailed && (completedCycles == churnCycles); - - printf("\n=== Group Churn Test Summary ===\n"); - printf("Base participants: %d (custom=%d, reference=%d)\n", - baseCount, customParticipants, referenceParticipants); - printf("Churn cycles: %d/%d completed\n", completedCycles, churnCycles); - printf("Video: %s\n", video ? "yes" : "no"); - printf("Stabilization: %ds\n", duration); - printf("Base connected: %d/%d\n", result.connectedCount, result.totalParticipants); - printf("Base audio received: %d/%d\n", result.audioReceivedCount, result.totalParticipants); - if (video) { - printf("Base video received: %d/%d\n", result.videoReceivedPairs, result.videoExpectedPairs); - } - printf("Result: %s\n", success ? "SUCCESS" : "FAILED"); - - // Clean up log files - for (const auto& s : baseStates) { - unlink(s->logPath.c_str()); - } - - fflush(stdout); - fflush(stderr); - _exit(success ? 0 : 1); -} diff --git a/tools/tgcalls_cli/group_churn_mode.h b/tools/tgcalls_cli/group_churn_mode.h deleted file mode 100644 index a42c2b203e..0000000000 --- a/tools/tgcalls_cli/group_churn_mode.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -int runGroupChurnMode( - int customParticipants, - int referenceParticipants, - int duration, - bool quiet, - bool video, - int churnCycles -); diff --git a/tools/tgcalls_cli/group_mode.cpp b/tools/tgcalls_cli/group_mode.cpp deleted file mode 100644 index 4dd6c4e0d6..0000000000 --- a/tools/tgcalls_cli/group_mode.cpp +++ /dev/null @@ -1,173 +0,0 @@ -#include "group_mode.h" -#include "group_participant.h" - -#include -#include -#include -#include -#include -#include - -// CGo header -#include "tools/go_sfu/go_sfu.h" - -int runGroupMode(int customParticipants, int referenceParticipants, int duration, bool quiet, bool video, const std::string& networkScenario) { - gGroupQuiet = quiet; - gGroupStartTime = std::chrono::steady_clock::now(); - - int participants = customParticipants + referenceParticipants; - if (participants < 2) { - fprintf(stderr, "Error: need at least 2 participants total\n"); - return 1; - } - - groupLog("Group", "initializing Go SFU..."); - - int rc = GoSfu_Init(); - if (rc != 0) { - fprintf(stderr, "Error: GoSfu_Init failed with %d\n", rc); - return 1; - } - - GoInt sfuHandle = GoSfu_Create(); - if (sfuHandle <= 0) { - fprintf(stderr, "Error: GoSfu_Create failed\n"); - return 1; - } - - groupLog("Group", "created SFU handle=%lld, custom=%d, reference=%d, duration=%ds", - (long long)sfuHandle, customParticipants, referenceParticipants, duration); - - auto threads = tgcalls::StaticThreads::getThreads(); - - // Create participants - std::vector> states; - bool anyFailed = false; - - for (int i = 0; i < participants; ++i) { - bool isReference = (i >= customParticipants); - auto state = createParticipant(i, isReference, sfuHandle, threads, quiet, video, &states); - if (!state) { - anyFailed = true; - continue; - } - states.push_back(std::move(state)); - } - - // Wait for all participants to connect - groupLog("Group", "waiting for connections..."); - bool allConnected = false; - auto waitStart = std::chrono::steady_clock::now(); - while (std::chrono::steady_clock::now() - waitStart < std::chrono::seconds(15)) { - int connectedCount = 0; - for (const auto& s : states) { - if (s->wasConnected.load()) connectedCount++; - } - if (connectedCount == (int)states.size()) { - allConnected = true; - groupLog("Group", "all %d participants connected", (int)states.size()); - break; - } - groupLog("Group", "connected: %d/%d", connectedCount, (int)states.size()); - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - } - - if (!allConnected) { - int connectedCount = 0; - for (const auto& s : states) { - if (s->wasConnected.load()) connectedCount++; - } - groupLog("Group", "connection timeout: %d/%d connected", connectedCount, (int)states.size()); - } - - // Run for the specified duration, optionally with network scenario. - if (!networkScenario.empty() && networkScenario == "step-down-up") { - // Scenario: start uncapped, then step down, step up, uncap. - // Split duration into 4 phases. - int phase = std::max(duration / 4, 2); - groupLog("Group", "network-scenario '%s': phase duration=%ds", networkScenario.c_str(), phase); - - // Phase 1: uncapped (should be layer 2 on high BW). - groupLog("Group", "phase 1: uncapped"); - std::this_thread::sleep_for(std::chrono::seconds(phase)); - - // Phase 2: cap to 80 kbps (should force downswitch to layer 0). - groupLog("Group", "phase 2: cap 80kbps"); - for (const auto& s : states) { - GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 80000); - } - std::this_thread::sleep_for(std::chrono::seconds(phase)); - - // Phase 3: cap to 200 kbps (should allow upswitch to layer 1). - groupLog("Group", "phase 3: cap 200kbps"); - for (const auto& s : states) { - GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 200000); - } - std::this_thread::sleep_for(std::chrono::seconds(phase)); - - // Phase 4: uncap (should allow upswitch to layer 2). - groupLog("Group", "phase 4: uncapped"); - for (const auto& s : states) { - GoSfu_SetNetworkParams(sfuHandle, s->id, 1, 0, 0, 0.0, 0); - } - std::this_thread::sleep_for(std::chrono::seconds(phase)); - } else { - groupLog("Group", "running for %d seconds...", duration); - std::this_thread::sleep_for(std::chrono::seconds(duration)); - } - - // Stop all participants (using GoSfu_Destroy for bulk teardown) - groupLog("Group", "stopping participants..."); - - // Stop video sources first - for (auto& s : states) { - if (s->videoSource) { - s->videoSource->Stop(); - } - } - - // Stop instances - std::atomic stopCount{0}; - std::mutex stopMutex; - std::condition_variable stopCv; - - for (const auto& s : states) { - if (s->instance) { - int pid_local = s->id; - s->instance->stop([&stopCount, &stopMutex, &stopCv, pid_local]() { - groupLog("Group", "participant %d stopped", pid_local); - stopCount.fetch_add(1); - std::lock_guard lock(stopMutex); - stopCv.notify_all(); - }); - } - } - - { - std::unique_lock lock(stopMutex); - stopCv.wait_for(lock, std::chrono::seconds(5), [&] { - return stopCount.load() >= (int)states.size(); - }); - } - - for (auto& s : states) { - s->instance.reset(); - } - - // Destroy SFU - GoSfu_Destroy(sfuHandle); - GoSfu_Shutdown(); - - // Validate and print summary - auto result = validateGroupState(states, video); - bool success = printGroupSummary(customParticipants, referenceParticipants, duration, video, result, anyFailed); - - // Clean up log files - for (const auto& s : states) { - unlink(s->logPath.c_str()); - } - - fflush(stdout); - fflush(stderr); - _exit(success ? 0 : 1); -} diff --git a/tools/tgcalls_cli/group_mode.h b/tools/tgcalls_cli/group_mode.h deleted file mode 100644 index 547b7e15fd..0000000000 --- a/tools/tgcalls_cli/group_mode.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include - -int runGroupMode(int customParticipants, int referenceParticipants, int duration, bool quiet, bool video, const std::string& networkScenario = ""); diff --git a/tools/tgcalls_cli/group_participant.cpp b/tools/tgcalls_cli/group_participant.cpp deleted file mode 100644 index 7960532efe..0000000000 --- a/tools/tgcalls_cli/group_participant.cpp +++ /dev/null @@ -1,442 +0,0 @@ -#include "group_participant.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "third-party/json11.hpp" - -// --------------------------------------------------------------------------- -// Globals -// --------------------------------------------------------------------------- - -std::chrono::steady_clock::time_point gGroupStartTime = std::chrono::steady_clock::now(); -std::atomic gGroupQuiet{false}; - -double groupElapsed() { - auto now = std::chrono::steady_clock::now(); - return std::chrono::duration(now - gGroupStartTime).count(); -} - -void groupLog(const char* tag, const char* fmt, ...) { - if (gGroupQuiet) return; - char buf[512]; - va_list ap; - va_start(ap, fmt); - vsnprintf(buf, sizeof(buf), fmt, ap); - va_end(ap); - fprintf(stderr, "[%7.3f] %s: %s\n", groupElapsed(), tag, buf); -} - -// --------------------------------------------------------------------------- -// GroupSineRecorder -// --------------------------------------------------------------------------- - -GroupSineRecorder::GroupSineRecorder() { - buffer_.resize(kFrameSamples * kChannels); -} - -tgcalls::AudioFrame GroupSineRecorder::Record() { - for (size_t i = 0; i < kFrameSamples; ++i) { - double t = static_cast(phase_) / kSampleRate; - int16_t sample = static_cast(kAmplitude * std::sin(2.0 * M_PI * kFrequency * t)); - for (size_t ch = 0; ch < kChannels; ++ch) { - buffer_[i * kChannels + ch] = sample; - } - ++phase_; - } - - tgcalls::AudioFrame frame; - frame.audio_samples = buffer_.data(); - frame.num_samples = kFrameSamples; - frame.bytes_per_sample = sizeof(int16_t); - frame.num_channels = kChannels; - frame.samples_per_sec = kSampleRate; - frame.elapsed_time_ms = 0; - frame.ntp_time_ms = 0; - return frame; -} - -int32_t GroupSineRecorder::WaitForUs() { - return 10000; // 10ms -} - -// --------------------------------------------------------------------------- -// GroupNoOpRenderer -// --------------------------------------------------------------------------- - -bool GroupNoOpRenderer::Render(const tgcalls::AudioFrame&) { return true; } - -// --------------------------------------------------------------------------- -// SimpleRequestMediaChannelDescriptionTask -// --------------------------------------------------------------------------- - -void SimpleRequestMediaChannelDescriptionTask::cancel() {} - -// --------------------------------------------------------------------------- -// createParticipant -// --------------------------------------------------------------------------- - -std::unique_ptr createParticipant( - int id, - bool isReference, - GoInt sfuHandle, - std::shared_ptr threads, - bool quiet, - bool video, - std::vector>* allStates -) { - auto state = std::make_unique(); - state->id = id; - state->isReference = isReference; - state->logPath = "/tmp/tgcalls_group_p" + std::to_string(id) + "_" + std::to_string(getpid()) + ".log"; - - std::string tag = "P" + std::to_string(id); - - auto recorder = std::make_shared(); - auto renderer = std::make_shared(); - - ParticipantState* statePtr = state.get(); - GoInt sfuH = sfuHandle; - - tgcalls::GroupInstanceDescriptor descriptor; - descriptor.threads = threads; - descriptor.config.need_log = true; - descriptor.config.logPath = {state->logPath}; - descriptor.networkStateUpdated = [statePtr, tag](tgcalls::GroupNetworkState networkState) { - groupLog(tag.c_str(), "network state: connected=%s", networkState.isConnected ? "true" : "false"); - statePtr->connected.store(networkState.isConnected); - if (networkState.isConnected) { - statePtr->wasConnected.store(true); - } - }; - descriptor.audioLevelsUpdated = [statePtr, tag](tgcalls::GroupLevelsUpdate const &update) { - for (const auto& level : update.updates) { - if (level.value.level > 0.01f) { - groupLog(tag.c_str(), "audio level: ssrc=%u level=%.3f voice=%d", - level.ssrc, level.value.level, level.value.voice); - } - if (level.ssrc != 0 && level.value.level > 0.05f) { - statePtr->receivedAudio.store(true); - } - } - }; - descriptor.createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( - renderer, recorder, - tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} - ); - - descriptor.requestMediaChannelDescriptions = [sfuH, tag, allStates]( - std::vector const &ssrcs, - std::function &&)> callback - ) -> std::shared_ptr { - std::set audioSsrcs; - for (const auto& s : *allStates) { - if (s->audioSsrc != 0) audioSsrcs.insert(s->audioSsrc); - } - std::vector descriptions; - for (uint32_t ssrc : ssrcs) { - GoInt ownerID = GoSfu_QuerySsrc(sfuH, (GoUint)ssrc); - bool isAudio = audioSsrcs.count(ssrc) > 0; - groupLog(tag.c_str(), "requestMediaChannelDescriptions: ssrc=%u -> owner=%lld type=%s", - ssrc, (long long)ownerID, isAudio ? "audio" : "video"); - tgcalls::MediaChannelDescription desc; - desc.type = isAudio ? tgcalls::MediaChannelDescription::Type::Audio - : tgcalls::MediaChannelDescription::Type::Video; - desc.audioSsrc = ssrc; - desc.userId = ownerID; - descriptions.push_back(std::move(desc)); - } - callback(std::move(descriptions)); - return std::make_shared(); - }; - - descriptor.outgoingAudioBitrateKbit = 32; - descriptor.disableIncomingChannels = false; - descriptor.useDummyChannel = true; - - // Video configuration - if (video) { - auto videoSource = FakeVideoTrackSource::Create(id); - state->videoSource = videoSource; - state->endpointId = std::to_string(id); - descriptor.videoContentType = tgcalls::VideoContentType::Generic; - descriptor.videoCodecPreferences = {tgcalls::VideoCodecName::H264}; - // Set the outgoing video min bitrate to 600 kbps so the sender's - // BWE floor is high enough to activate all 3 simulcast layers - // (audio 32k + L0 min 50k + L1 min 100k + L2 min 300k = 482k). - // On localhost, delay-based BWE over the loopback pacer has been - // observed to drift down to ~80 kbps, keeping L2 disabled. Clamping - // the min forces the encoder to keep L2 producing. - descriptor.minOutgoingVideoBitrateKbit = 600; - descriptor.getVideoSource = [videoSource]() -> webrtc::scoped_refptr { - return videoSource; - }; - - descriptor.dataChannelMessageReceived = [statePtr, sfuH, tag](std::string const &message) { - std::string parseErr; - auto json = json11::Json::parse(message, parseErr); - if (!parseErr.empty() || !json.is_object()) return; - auto cls = json["colibriClass"].string_value(); - if (cls != "ActiveVideoSsrcs") return; - - auto ssrcsArray = json["ssrcs"].array_items(); - if (ssrcsArray.empty()) return; - - std::vector videoChannels; - for (const auto& entry : ssrcsArray) { - std::string endpointId = entry["endpointId"].string_value(); - if (endpointId == statePtr->endpointId) continue; - - { - std::lock_guard lock(statePtr->videoSinksMutex); - if (statePtr->videoSinks.count(endpointId) > 0) continue; - } - - int remoteId = 0; - if (sscanf(endpointId.c_str(), "%d", &remoteId) != 1) continue; - - char* ssrcsRaw = GoSfu_QueryVideoSsrcs(sfuH, (GoInt)remoteId); - if (!ssrcsRaw) continue; - std::string ssrcsJson(ssrcsRaw); - GoSfu_Free(ssrcsRaw); - - std::string err2; - auto layers = json11::Json::parse(ssrcsJson, err2); - if (!err2.empty() || !layers.is_array() || layers.array_items().empty()) continue; - - tgcalls::VideoChannelDescription desc; - desc.audioSsrc = 0; - desc.userId = remoteId; - desc.endpointId = endpointId; - desc.maxQuality = tgcalls::VideoChannelDescription::Quality::Full; - desc.minQuality = tgcalls::VideoChannelDescription::Quality::Full; - - tgcalls::MediaSsrcGroup simGroup; - simGroup.semantics = "SIM"; - for (const auto& layer : layers.array_items()) { - uint32_t ssrc = static_cast(static_cast(layer["ssrc"].number_value())); - uint32_t fidSsrc = static_cast(static_cast(layer["fidSsrc"].number_value())); - if (ssrc == 0) continue; - simGroup.ssrcs.push_back(ssrc); - if (fidSsrc != 0) { - tgcalls::MediaSsrcGroup fidGroup; - fidGroup.semantics = "FID"; - fidGroup.ssrcs = {ssrc, fidSsrc}; - desc.ssrcGroups.push_back(std::move(fidGroup)); - } - } - desc.ssrcGroups.insert(desc.ssrcGroups.begin(), std::move(simGroup)); - videoChannels.push_back(std::move(desc)); - - auto sink = std::make_shared(); - { - std::lock_guard lock(statePtr->videoSinksMutex); - statePtr->videoSinks[endpointId] = sink; - } - statePtr->instance->addIncomingVideoOutput( - endpointId, - std::weak_ptr>(sink)); - - groupLog(tag.c_str(), "ActiveVideoSsrcs: adding video channel for endpoint %s", endpointId.c_str()); - } - - if (!videoChannels.empty()) { - statePtr->instance->setRequestedVideoChannels(std::move(videoChannels)); - } - }; - } else { - descriptor.videoContentType = tgcalls::VideoContentType::None; - } - - // Create instance - if (isReference) { - state->instance = std::make_unique(std::move(descriptor)); - groupLog(tag.c_str(), "created GroupInstanceReferenceImpl"); - } else { - state->instance = std::make_unique(std::move(descriptor)); - groupLog(tag.c_str(), "created GroupInstanceCustomImpl"); - } - - // Set connection mode - state->instance->setConnectionMode( - tgcalls::GroupConnectionMode::GroupConnectionModeRtc, false, false); - - // Emit join payload - std::mutex joinMutex; - std::condition_variable joinCv; - bool joinReady = false; - std::string joinJson; - uint32_t joinSsrc = 0; - - state->instance->emitJoinPayload([&](tgcalls::GroupJoinPayload const &payload) { - std::lock_guard lock(joinMutex); - joinJson = payload.json; - joinSsrc = payload.audioSsrc; - joinReady = true; - joinCv.notify_one(); - }); - - { - std::unique_lock lock(joinMutex); - if (!joinCv.wait_for(lock, std::chrono::seconds(5), [&] { return joinReady; })) { - fprintf(stderr, "Error: emitJoinPayload timed out for participant %d\n", id); - return nullptr; - } - } - - state->audioSsrc = joinSsrc; - groupLog(tag.c_str(), "join payload ready: ssrc=%u, json=%zu bytes", joinSsrc, joinJson.size()); - - // Join SFU - GoInt iceControlling = isReference ? 0 : 1; - char* responseRaw = GoSfu_Join(sfuHandle, (GoInt)id, const_cast(joinJson.c_str()), iceControlling); - if (!responseRaw) { - fprintf(stderr, "Error: GoSfu_Join returned null for participant %d\n", id); - return nullptr; - } - std::string response(responseRaw); - GoSfu_Free(responseRaw); - - if (response.find("\"error\"") != std::string::npos) { - fprintf(stderr, "Error: GoSfu_Join failed for participant %d: %s\n", id, response.c_str()); - return nullptr; - } - - groupLog(tag.c_str(), "SFU join response: %zu bytes", response.size()); - - state->instance->setJoinResponsePayload(response); - state->instance->setIsMuted(false); - - groupLog(tag.c_str(), "joined and unmuted"); - return state; -} - -// --------------------------------------------------------------------------- -// stopParticipant -// --------------------------------------------------------------------------- - -void stopParticipant(ParticipantState* state, GoInt sfuHandle) { - if (!state || !state->instance) return; - - std::string tag = "P" + std::to_string(state->id); - - // Remove from SFU first so broadcasts go out to remaining participants. - GoInt rc = GoSfu_Leave(sfuHandle, (GoInt)state->id); - if (rc != 0) { - groupLog(tag.c_str(), "GoSfu_Leave returned %lld (may already be removed)", (long long)rc); - } - - // Stop video source. - if (state->videoSource) { - state->videoSource->Stop(); - } - - // Stop instance with timeout. Heap-allocate sync state so the stop callback - // is safe even if it fires after the 5s timeout (avoids stack-frame UB). - struct StopState { - std::mutex mu; - std::condition_variable cv; - std::atomic done{false}; - }; - auto stopState = std::make_shared(); - - state->instance->stop([stopState]() { - stopState->done.store(true); - std::lock_guard lock(stopState->mu); - stopState->cv.notify_all(); - }); - - { - std::unique_lock lock(stopState->mu); - stopState->cv.wait_for(lock, std::chrono::seconds(5), [&] { return stopState->done.load(); }); - } - - state->instance.reset(); - - // Clean up log file. - unlink(state->logPath.c_str()); - - groupLog(tag.c_str(), "stopped and cleaned up"); -} - -// --------------------------------------------------------------------------- -// validateGroupState -// --------------------------------------------------------------------------- - -GroupValidationResult validateGroupState( - const std::vector>& states, - bool video -) { - GroupValidationResult result{}; - result.totalParticipants = static_cast(states.size()); - - for (const auto& s : states) { - if (s->wasConnected.load()) result.connectedCount++; - if (s->receivedAudio.load()) result.audioReceivedCount++; - } - - if (video) { - int videoParticipants = 0; - for (const auto& s : states) { - if (s->videoSource) videoParticipants++; - } - result.videoExpectedPairs = videoParticipants * (videoParticipants - 1); - - for (const auto& s : states) { - std::lock_guard lock(s->videoSinksMutex); - for (const auto& [endpointId, sink] : s->videoSinks) { - int frames = sink->frameCount(); - if (frames > 0) { - result.videoReceivedPairs++; - } - groupLog("Validate", "P%d <- endpoint %s: %d video frames (%dx%d)", - s->id, endpointId.c_str(), frames, - sink->lastWidth(), sink->lastHeight()); - } - } - } - - result.success = (result.connectedCount == result.totalParticipants && - result.audioReceivedCount == result.totalParticipants); - if (video && result.videoExpectedPairs > 0) { - result.success = result.success && (result.videoReceivedPairs >= result.videoExpectedPairs); - } - - return result; -} - -// --------------------------------------------------------------------------- -// printGroupSummary -// --------------------------------------------------------------------------- - -bool printGroupSummary( - int customParticipants, - int referenceParticipants, - int duration, - bool video, - const GroupValidationResult& result, - bool anyFailed -) { - bool success = result.success && !anyFailed; - - printf("\n=== Group Call Summary ===\n"); - printf("Custom participants: %d\n", customParticipants); - printf("Reference participants: %d\n", referenceParticipants); - printf("Total participants: %d\n", result.totalParticipants); - printf("Duration: %ds\n", duration); - printf("SFU: Go/Pion (in-process)\n"); - printf("Connected: %d/%d\n", result.connectedCount, result.totalParticipants); - printf("Audio received: %d/%d\n", result.audioReceivedCount, result.totalParticipants); - if (video) { - printf("Video received: %d/%d\n", result.videoReceivedPairs, result.videoExpectedPairs); - } - printf("Result: %s\n", success ? "SUCCESS" : "FAILED"); - - return success; -} diff --git a/tools/tgcalls_cli/group_participant.h b/tools/tgcalls_cli/group_participant.h deleted file mode 100644 index 8f3e1efb09..0000000000 --- a/tools/tgcalls_cli/group_participant.h +++ /dev/null @@ -1,143 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "group/GroupInstanceCustomImpl.h" -#include "group/GroupInstanceImpl.h" -#include "group/GroupInstanceReferenceImpl.h" -#include "FakeAudioDeviceModule.h" -#include "StaticThreads.h" -#include "AudioFrame.h" -#include "fake_video_source.h" -#include "fake_video_sink.h" - -// CGo header -#include "tools/go_sfu/go_sfu.h" - -// --------------------------------------------------------------------------- -// Logging helpers -// --------------------------------------------------------------------------- - -extern std::chrono::steady_clock::time_point gGroupStartTime; -extern std::atomic gGroupQuiet; - -double groupElapsed(); -void groupLog(const char* tag, const char* fmt, ...); - -// --------------------------------------------------------------------------- -// GroupSineRecorder - generates 440 Hz sine tone -// --------------------------------------------------------------------------- - -class GroupSineRecorder : public tgcalls::FakeAudioDeviceModule::Recorder { -public: - GroupSineRecorder(); - tgcalls::AudioFrame Record() override; - int32_t WaitForUs() override; - -private: - static constexpr size_t kSampleRate = 48000; - static constexpr size_t kChannels = 2; - static constexpr size_t kFrameSamples = 480; - static constexpr double kFrequency = 440.0; - static constexpr double kAmplitude = 3000.0; - - std::vector buffer_; - uint64_t phase_ = 0; -}; - -// --------------------------------------------------------------------------- -// GroupNoOpRenderer - discards received audio -// --------------------------------------------------------------------------- - -class GroupNoOpRenderer : public tgcalls::FakeAudioDeviceModule::Renderer { -public: - bool Render(const tgcalls::AudioFrame&) override; -}; - -// --------------------------------------------------------------------------- -// SimpleRequestMediaChannelDescriptionTask -// --------------------------------------------------------------------------- - -class SimpleRequestMediaChannelDescriptionTask : public tgcalls::RequestMediaChannelDescriptionTask { -public: - void cancel() override; -}; - -// --------------------------------------------------------------------------- -// ParticipantState -// --------------------------------------------------------------------------- - -struct ParticipantState { - int id; - bool isReference; - std::unique_ptr instance; - std::atomic connected{false}; - std::atomic wasConnected{false}; - std::atomic receivedAudio{false}; - uint32_t audioSsrc{0}; - std::string logPath; - - // Video fields - std::string endpointId; - rtc::scoped_refptr videoSource; - std::mutex videoSinksMutex; - std::map> videoSinks; -}; - -// --------------------------------------------------------------------------- -// GroupValidationResult -// --------------------------------------------------------------------------- - -struct GroupValidationResult { - int totalParticipants; - int connectedCount; - int audioReceivedCount; - int videoReceivedPairs; - int videoExpectedPairs; - bool success; -}; - -// --------------------------------------------------------------------------- -// Participant lifecycle functions -// --------------------------------------------------------------------------- - -// Creates a fully initialized participant: builds descriptor, creates instance, -// joins SFU, sets join response, unmutes. Returns nullptr on failure. -std::unique_ptr createParticipant( - int id, - bool isReference, - GoInt sfuHandle, - std::shared_ptr threads, - bool quiet, - bool video, - std::vector>* allStates -); - -// Clean teardown: GoSfu_Leave, stop video, stop instance, reset. -void stopParticipant(ParticipantState* state, GoInt sfuHandle); - -// Validates group state: connection, audio, video. Returns result struct. -GroupValidationResult validateGroupState( - const std::vector>& states, - bool video -); - -// Prints a group call summary to stdout. Returns the success boolean. -bool printGroupSummary( - int customParticipants, - int referenceParticipants, - int duration, - bool video, - const GroupValidationResult& result, - bool anyFailed -); diff --git a/tools/tgcalls_cli/main.cpp b/tools/tgcalls_cli/main.cpp deleted file mode 100644 index 7ed581a8d8..0000000000 --- a/tools/tgcalls_cli/main.cpp +++ /dev/null @@ -1,602 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "group_mode.h" -#include "group_churn_mode.h" -#include "Instance.h" -#include "FakeAudioDeviceModule.h" -#include "VideoCaptureInterface.h" -#include "v2/InstanceV2Impl.h" -#include "v2/InstanceV2CompatImpl.h" -#include "v2/InstanceV2ReferenceImpl.h" - -#include "modules/audio_device/include/audio_device.h" -#include "api/task_queue/task_queue_factory.h" - -// Stub: AudioDeviceModule::Create is referenced by InstanceV2Impl as a fallback -// but never called when createAudioDeviceModule is provided in the Descriptor. -namespace webrtc { -rtc::scoped_refptr AudioDeviceModule::Create( - AudioDeviceModule::AudioLayer audio_layer, - TaskQueueFactory* task_queue_factory) { - return nullptr; -} -} // namespace webrtc - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -static auto gStartTime = std::chrono::steady_clock::now(); - -static double elapsed() { - auto now = std::chrono::steady_clock::now(); - return std::chrono::duration(now - gStartTime).count(); -} - -static bool gQuiet = false; - -static void logMsg(const char* role, const char* fmt, ...) { - if (gQuiet) return; - char buf[512]; - va_list ap; - va_start(ap, fmt); - vsnprintf(buf, sizeof(buf), fmt, ap); - va_end(ap); - fprintf(stderr, "[%7.3f] %s: %s\n", elapsed(), role, buf); -} - -static const char* stateName(tgcalls::State s) { - switch (s) { - case tgcalls::State::WaitInit: return "WaitInit"; - case tgcalls::State::WaitInitAck: return "WaitInitAck"; - case tgcalls::State::Established: return "Established"; - case tgcalls::State::Failed: return "Failed"; - case tgcalls::State::Reconnecting:return "Reconnecting"; - } - return "Unknown"; -} - -static std::string hexEncode(const std::array& data) { - char buf[33]; - for (size_t i = 0; i < 16; ++i) { - snprintf(buf + i * 2, 3, "%02x", data[i]); - } - return std::string(buf, 32); -} - -static tgcalls::RtcServer makeReflectorServer(const std::string& host, uint16_t port, - const std::array& peerTag) { - tgcalls::RtcServer server; - server.id = 1; - server.host = host; - server.port = port; - server.login = "reflector"; - server.password = hexEncode(peerTag); - server.isTurn = true; - server.isTcp = false; - return server; -} - -// --------------------------------------------------------------------------- -// SineRecorder - generates 440 Hz sine tone -// --------------------------------------------------------------------------- - -class SineRecorder : public tgcalls::FakeAudioDeviceModule::Recorder { -public: - SineRecorder() { - buffer_.resize(kFrameSamples * kChannels); - } - - tgcalls::AudioFrame Record() override { - for (size_t i = 0; i < kFrameSamples; ++i) { - double t = static_cast(phase_) / kSampleRate; - int16_t sample = static_cast(kAmplitude * std::sin(2.0 * M_PI * kFrequency * t)); - for (size_t ch = 0; ch < kChannels; ++ch) { - buffer_[i * kChannels + ch] = sample; - } - ++phase_; - } - - tgcalls::AudioFrame frame; - frame.audio_samples = buffer_.data(); - frame.num_samples = kFrameSamples; - frame.bytes_per_sample = sizeof(int16_t); - frame.num_channels = kChannels; - frame.samples_per_sec = kSampleRate; - frame.elapsed_time_ms = 0; - frame.ntp_time_ms = 0; - return frame; - } - - int32_t WaitForUs() override { - return 10000; // 10ms - } - -private: - static constexpr size_t kSampleRate = 48000; - static constexpr size_t kChannels = 2; - static constexpr size_t kFrameSamples = 480; // 10ms at 48kHz - static constexpr double kFrequency = 440.0; - static constexpr double kAmplitude = 3000.0; - - std::vector buffer_; - uint64_t phase_ = 0; -}; - -// --------------------------------------------------------------------------- -// NoOpRenderer - discards received audio (validation is done via BWE stats) -// --------------------------------------------------------------------------- - -class NoOpRenderer : public tgcalls::FakeAudioDeviceModule::Renderer { -public: - bool Render(const tgcalls::AudioFrame&) override { return true; } -}; - -// --------------------------------------------------------------------------- -// SignalingBridge -// --------------------------------------------------------------------------- - -struct SignalingBridge { - std::mutex mutex; - std::shared_ptr caller; - std::shared_ptr callee; - - // Network simulation - double dropRate = 0.0; - int delayMinMs = 0; - int delayMaxMs = 0; - std::mt19937 rng{std::random_device{}()}; - - void deliver(const char* fromRole, const std::vector& data, - std::shared_ptr& target) { - if (dropRate > 0.0) { - std::uniform_real_distribution dropDist(0.0, 1.0); - if (dropDist(rng) < dropRate) { - logMsg(fromRole, "signaling DROPPED (%zu bytes)", data.size()); - return; - } - } - if (delayMaxMs > 0) { - std::uniform_int_distribution delayDist(delayMinMs, delayMaxMs); - int delayMs = delayDist(rng); - if (delayMs > 0) { - logMsg(fromRole, "signaling delayed %dms (%zu bytes)", delayMs, data.size()); - auto dataCopy = data; - auto targetWeak = std::weak_ptr(target); - std::thread([dataCopy, targetWeak, delayMs]() { - std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); - if (auto t = targetWeak.lock()) { - t->receiveSignalingData(dataCopy); - } - }).detach(); - return; - } - } - if (target) { - target->receiveSignalingData(data); - } - } -}; - -// --------------------------------------------------------------------------- -// CallState -// --------------------------------------------------------------------------- - -struct CallState { - std::mutex mutex; - tgcalls::State callerState = tgcalls::State::WaitInit; - tgcalls::State calleeState = tgcalls::State::WaitInit; - double establishedAt = -1.0; - std::vector errors; -}; - -// --------------------------------------------------------------------------- -// main -// --------------------------------------------------------------------------- - -int main(int argc, char* argv[]) { - int duration = 10; - std::string mode; - std::string reflectorAddr; - std::string reflectorList; - std::string version = "13.0.0"; - std::string version2; - double dropRate = 0.0; - int delayMinMs = 0; - int delayMaxMs = 0; - int participants = 3; - int referenceParticipants = 0; - bool enableVideo = false; - int churnCycles = 100; - std::string networkScenario; - - for (int i = 1; i < argc; ++i) { - if (std::string(argv[i]) == "--duration" && i + 1 < argc) { - duration = std::atoi(argv[++i]); - } else if (std::string(argv[i]) == "--quiet") { - gQuiet = true; - } else if (std::string(argv[i]) == "--mode" && i + 1 < argc) { - mode = argv[++i]; - } else if (std::string(argv[i]) == "--reflector" && i + 1 < argc) { - reflectorAddr = argv[++i]; - } else if (std::string(argv[i]) == "--reflector-list" && i + 1 < argc) { - reflectorList = argv[++i]; - } else if (std::string(argv[i]) == "--drop-rate" && i + 1 < argc) { - dropRate = std::atof(argv[++i]); - } else if (std::string(argv[i]) == "--version" && i + 1 < argc) { - version = argv[++i]; - } else if (std::string(argv[i]) == "--version2" && i + 1 < argc) { - version2 = argv[++i]; - } else if (std::string(argv[i]) == "--participants" && i + 1 < argc) { - participants = std::atoi(argv[++i]); - } else if (std::string(argv[i]) == "--reference-participants" && i + 1 < argc) { - referenceParticipants = std::atoi(argv[++i]); - } else if (std::string(argv[i]) == "--video") { - enableVideo = true; - } else if (std::string(argv[i]) == "--churn-cycles" && i + 1 < argc) { - churnCycles = std::atoi(argv[++i]); - } else if (std::string(argv[i]) == "--network-scenario" && i + 1 < argc) { - networkScenario = argv[++i]; - } else if (std::string(argv[i]) == "--delay" && i + 1 < argc) { - std::string delayStr = argv[++i]; - auto dashPos = delayStr.find('-'); - if (dashPos != std::string::npos) { - delayMinMs = std::atoi(delayStr.substr(0, dashPos).c_str()); - delayMaxMs = std::atoi(delayStr.substr(dashPos + 1).c_str()); - } else { - delayMinMs = 0; - delayMaxMs = std::atoi(delayStr.c_str()); - } - } - } - - if (version2.empty()) { - version2 = version; - } - - // If --reflector-list provided, pick one at random - if (!reflectorList.empty()) { - std::vector addrs; - size_t pos = 0; - while (pos < reflectorList.size()) { - size_t next = reflectorList.find(',', pos); - if (next == std::string::npos) next = reflectorList.size(); - std::string addr = reflectorList.substr(pos, next - pos); - if (!addr.empty()) addrs.push_back(addr); - pos = next + 1; - } - if (addrs.empty()) { - fprintf(stderr, "Error: --reflector-list is empty\n"); - return 1; - } - std::random_device rd; - std::mt19937 rng(rd()); - std::uniform_int_distribution dist(0, addrs.size() - 1); - reflectorAddr = addrs[dist(rng)]; - if (reflectorAddr.rfind(':') == std::string::npos) { - std::uniform_int_distribution portDist(596, 599); - reflectorAddr += ":" + std::to_string(portDist(rng)); - } - if (mode.empty()) mode = "reflector"; - } - - // Validate --mode - if (mode.empty()) { - fprintf(stderr, "Error: --mode is required (p2p, reflector, group, or group-churn)\n"); - return 1; - } - if (mode != "p2p" && mode != "reflector" && mode != "group" && mode != "group-churn") { - fprintf(stderr, "Error: --mode must be 'p2p', 'reflector', 'group', or 'group-churn'\n"); - return 1; - } - - // Group mode: dispatch to separate implementation - if (mode == "group") { - return runGroupMode(participants, referenceParticipants, duration, gQuiet, enableVideo, networkScenario); - } - if (mode == "group-churn") { - return runGroupChurnMode(participants, referenceParticipants, duration, gQuiet, enableVideo, churnCycles); - } - if (mode == "reflector" && reflectorAddr.empty()) { - fprintf(stderr, "Error: --reflector host:port is required with --mode reflector\n"); - return 1; - } - if (mode == "p2p" && !reflectorAddr.empty()) { - fprintf(stderr, "Error: --reflector cannot be used with --mode p2p\n"); - return 1; - } - - // Parse reflector address - std::string reflectorHost; - uint16_t reflectorPort = 0; - if (mode == "reflector") { - auto colonPos = reflectorAddr.rfind(':'); - if (colonPos == std::string::npos) { - fprintf(stderr, "Error: --reflector must be in host:port format\n"); - return 1; - } - reflectorHost = reflectorAddr.substr(0, colonPos); - reflectorPort = static_cast(std::atoi(reflectorAddr.substr(colonPos + 1).c_str())); - if (reflectorPort == 0) { - fprintf(stderr, "Error: invalid reflector port\n"); - return 1; - } - } - - // Generate peer tags for reflector mode - std::array callerPeerTag{}; - std::array calleePeerTag{}; - if (mode == "reflector") { - std::random_device rd; - std::mt19937 rng(rd()); - std::uniform_int_distribution dist(0, 255); - for (auto& b : callerPeerTag) { - b = static_cast(dist(rng)); - } - calleePeerTag = callerPeerTag; - callerPeerTag[0] = 0x00; - calleePeerTag[0] = 0x01; - } - - // Register implementations - tgcalls::Register(); - tgcalls::Register(); - tgcalls::Register(); - - // Create shared encryption key - auto keyData = std::make_shared>(); - { - std::mt19937 rng(42); - std::uniform_int_distribution dist(0, 255); - for (auto& b : *keyData) { - b = static_cast(dist(rng)); - } - } - - // Bridge and state - auto bridge = std::make_shared(); - bridge->dropRate = dropRate; - bridge->delayMinMs = delayMinMs; - bridge->delayMaxMs = delayMaxMs; - auto callState = std::make_shared(); - - // Audio components - auto callerRecorder = std::make_shared(); - auto callerRenderer = std::make_shared(); - auto calleeRecorder = std::make_shared(); - auto calleeRenderer = std::make_shared(); - - // Stats log paths (per-process to avoid collisions in parallel runs) - std::string callerStatsPath = "/tmp/tgcalls_cli_caller_" + std::to_string(getpid()) + ".json"; - std::string calleeStatsPath = "/tmp/tgcalls_cli_callee_" + std::to_string(getpid()) + ".json"; - - // --- Caller descriptor --- - auto callerDesc = (tgcalls::Descriptor){ - .version = version, - .config = { - .initializationTimeout = 10.0, - .receiveTimeout = 10.0, - .enableP2P = (mode == "p2p"), - .statsLogPath = {callerStatsPath}, - }, - .rtcServers = (mode == "reflector") - ? std::vector{makeReflectorServer(reflectorHost, reflectorPort, callerPeerTag)} - : std::vector{}, - .encryptionKey = tgcalls::EncryptionKey(keyData, true), - .stateUpdated = [callState](tgcalls::State state) { - logMsg("Caller", "state -> %s", stateName(state)); - std::lock_guard lock(callState->mutex); - callState->callerState = state; - if (state == tgcalls::State::Established && callState->establishedAt < 0) { - callState->establishedAt = elapsed(); - } - if (state == tgcalls::State::Failed) { - callState->errors.push_back("Caller entered Failed state"); - } - }, - .signalingDataEmitted = [bridge](const std::vector& data) { - logMsg("Caller", "signaling data emitted (%zu bytes)", data.size()); - std::lock_guard lock(bridge->mutex); - bridge->deliver("Caller", data, bridge->callee); - }, - .createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( - callerRenderer, callerRecorder, - tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} - ), - }; - - // --- Callee descriptor --- - auto calleeDesc = (tgcalls::Descriptor){ - .version = version2, - .config = { - .initializationTimeout = 10.0, - .receiveTimeout = 10.0, - .enableP2P = (mode == "p2p"), - .statsLogPath = {calleeStatsPath}, - }, - .rtcServers = (mode == "reflector") - ? std::vector{makeReflectorServer(reflectorHost, reflectorPort, calleePeerTag)} - : std::vector{}, - .encryptionKey = tgcalls::EncryptionKey(keyData, false), - .stateUpdated = [callState](tgcalls::State state) { - logMsg("Callee", "state -> %s", stateName(state)); - std::lock_guard lock(callState->mutex); - callState->calleeState = state; - if (state == tgcalls::State::Established && callState->establishedAt < 0) { - callState->establishedAt = elapsed(); - } - if (state == tgcalls::State::Failed) { - callState->errors.push_back("Callee entered Failed state"); - } - }, - .signalingDataEmitted = [bridge](const std::vector& data) { - logMsg("Callee", "signaling data emitted (%zu bytes)", data.size()); - std::lock_guard lock(bridge->mutex); - bridge->deliver("Callee", data, bridge->caller); - }, - .createAudioDeviceModule = tgcalls::FakeAudioDeviceModule::Creator( - calleeRenderer, calleeRecorder, - tgcalls::FakeAudioDeviceModule::Options{.samples_per_sec = 48000, .num_channels = 2} - ), - }; - - // Create instances - auto callerInstance = std::shared_ptr( - tgcalls::Meta::Create(version, std::move(callerDesc)).release()); - if (!callerInstance) { - fprintf(stderr, "Error: unknown version '%s'\n", version.c_str()); - return 1; - } - logMsg("Caller", "created (version %s)", version.c_str()); - - auto calleeInstance = std::shared_ptr( - tgcalls::Meta::Create(version2, std::move(calleeDesc)).release()); - if (!calleeInstance) { - fprintf(stderr, "Error: unknown callee version '%s'\n", version2.c_str()); - return 1; - } - logMsg("Callee", "created (version %s)", version2.c_str()); - - // Wire bridge - { - std::lock_guard lock(bridge->mutex); - bridge->caller = callerInstance; - bridge->callee = calleeInstance; - } - - logMsg("Main", "sleeping for %d seconds...", duration); - std::this_thread::sleep_for(std::chrono::seconds(duration)); - - // Stop both instances - logMsg("Main", "stopping instances..."); - - std::atomic stopCount{0}; - std::mutex stopMutex; - std::condition_variable stopCv; - - auto onStopped = [&](const char* role) { - return [&, role](tgcalls::FinalState) { - logMsg(role, "stopped"); - stopCount.fetch_add(1); - std::lock_guard lock(stopMutex); - stopCv.notify_all(); - }; - }; - - callerInstance->stop(onStopped("Caller")); - calleeInstance->stop(onStopped("Callee")); - - // Wait for both stop callbacks (up to 5 seconds) - { - std::unique_lock lock(stopMutex); - stopCv.wait_for(lock, std::chrono::seconds(5), [&] { - return stopCount.load() >= 2; - }); - } - - // Release instances — clear bridge first to prevent signaling during teardown - { - std::lock_guard lock(bridge->mutex); - bridge->caller.reset(); - bridge->callee.reset(); - } - callerInstance.reset(); - calleeInstance.reset(); - - // Read stats logs: count bitrate records and check for non-zero BWE - struct StatsResult { - int bitrateRecords = 0; - bool hasNonZeroBwe = false; - }; - auto parseStatsLog = [](const std::string& path) -> StatsResult { - StatsResult result; - std::ifstream f(path); - if (!f.is_open()) return result; - std::string content((std::istreambuf_iterator(f)), - std::istreambuf_iterator()); - size_t pos = 0; - while ((pos = content.find("\"b\":", pos)) != std::string::npos) { - pos += 4; - result.bitrateRecords++; - // Parse the integer value after "b": - int val = std::atoi(content.c_str() + pos); - if (val > 0) { - result.hasNonZeroBwe = true; - } - } - return result; - }; - - auto callerStats = parseStatsLog(callerStatsPath); - auto calleeStats = parseStatsLog(calleeStatsPath); - unlink(callerStatsPath.c_str()); - unlink(calleeStatsPath.c_str()); - - // Print summary - { - std::lock_guard lock(callState->mutex); - - bool established = (callState->establishedAt >= 0); - - printf("\n=== Call Summary ===\n"); - printf("Duration: %ds\n", duration); - if (dropRate > 0.0 || delayMaxMs > 0) { - printf("Signaling: drop=%.0f%% delay=%d-%dms\n", - dropRate * 100.0, delayMinMs, delayMaxMs); - } - if (mode == "reflector") { - printf("Mode: reflector (%s:%d)\n", reflectorHost.c_str(), reflectorPort); - } else { - printf("Mode: p2p\n"); - } - printf("Caller state: %s\n", stateName(callState->callerState)); - printf("Callee state: %s\n", stateName(callState->calleeState)); - if (callState->establishedAt >= 0) { - printf("Call established: yes (at %.3fs)\n", callState->establishedAt); - } else { - printf("Call established: no\n"); - } - bool bweNonZero = callerStats.hasNonZeroBwe && calleeStats.hasNonZeroBwe; - - printf("Stats log: caller=%d callee=%d bitrate records\n", - callerStats.bitrateRecords, calleeStats.bitrateRecords); - printf("BWE non-zero: %s\n", bweNonZero ? "yes" : "no"); - - bool statsCollected = (callerStats.bitrateRecords > 0 && calleeStats.bitrateRecords > 0); - - if (callState->errors.empty()) { - printf("Errors: none\n"); - } else { - printf("Errors:\n"); - for (const auto& err : callState->errors) { - printf(" - %s\n", err.c_str()); - } - } - - // Use _exit() to skip static destruction. ThreadLocalObject's destructor - // posts fire-and-forget cleanup tasks to the tgcalls media thread. If we - // return normally, static destruction tears down the StaticThreads thread - // pool while those tasks may still be executing, causing "pure virtual - // function called" when a half-destroyed object's vtable is accessed. - fflush(stdout); - fflush(stderr); - _exit(established && statsCollected && bweNonZero ? 0 : 1); - } -} diff --git a/tools/tgcalls_cli/run-local-test.sh b/tools/tgcalls_cli/run-local-test.sh deleted file mode 100755 index 29fc072766..0000000000 --- a/tools/tgcalls_cli/run-local-test.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# Run N parallel P2P tests locally and report aggregate results. -# -# Usage: -# ./run-local-test.sh # 100 calls, 15s each, 30% loss -# ./run-local-test.sh -n 1000 # 1000 calls -# ./run-local-test.sh -n 500 -j 200 # 500 calls, 200 parallel -# ./run-local-test.sh -n 100 -d 30 # 100 calls, 30s each -# ./run-local-test.sh --drop-rate 0.5 # 50% loss - -set -euo pipefail - -BINARY="./bazel-bin/tools/tgcalls_cli/tgcalls_cli" -NUM=100 -PARALLEL=150 -DURATION=15 -DROP_RATE=0.3 -DELAY="50-200" -MODE="p2p" -VERSION="13.0.0" - -while [[ $# -gt 0 ]]; do - case $1 in - -n) NUM="$2"; shift 2 ;; - -j) PARALLEL="$2"; shift 2 ;; - -d) DURATION="$2"; shift 2 ;; - --drop-rate) DROP_RATE="$2"; shift 2 ;; - --delay) DELAY="$2"; shift 2 ;; - --mode) MODE="$2"; shift 2 ;; - --version) VERSION="$2"; shift 2 ;; - *) echo "Usage: $0 [-n NUM] [-j PARALLEL] [-d DURATION] [--drop-rate RATE] [--delay MIN-MAX] [--mode MODE] [--version VER]"; exit 1 ;; - esac -done - -if [ ! -x "$BINARY" ]; then - echo "Binary not found: $BINARY" - echo "Run: ./build-input/bazel-8.4.2 build //tools/tgcalls_cli:tgcalls_cli" - exit 1 -fi - -TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT - -echo "Running $NUM calls ($PARALLEL parallel, ${DURATION}s each, drop=${DROP_RATE}, delay=${DELAY}ms, mode=${MODE}, version=${VERSION})" - -START=$(date +%s) -launched=0 -wave=0 - -while [ $launched -lt $NUM ]; do - wave=$((wave + 1)) - remaining=$((NUM - launched)) - batch=$((remaining > PARALLEL ? PARALLEL : remaining)) - - pids=() - for i in $(seq 1 $batch); do - id=$((launched + i)) - ( - if "$BINARY" --mode "$MODE" --duration "$DURATION" \ - --drop-rate "$DROP_RATE" --delay "$DELAY" --version "$VERSION" --quiet \ - > /dev/null 2>&1; then - echo "pass" > "$TMPDIR/$id" - else - echo "fail" > "$TMPDIR/$id" - fi - ) & - pids+=($!) - done - - for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true - done - - launched=$((launched + batch)) - echo " Wave $wave: $launched/$NUM done" -done - -END=$(date +%s) -ELAPSED=$((END - START)) - -# Tally -success=0 -failed=0 -for f in "$TMPDIR"/*; do - [ -f "$f" ] || continue - if [ "$(cat "$f")" = "pass" ]; then - success=$((success + 1)) - else - failed=$((failed + 1)) - fi -done - -echo "" -echo "=== Local Mass Test Results ===" -echo "Total: $NUM" -echo "Success: $success" -echo "Failed: $failed" -if [ $NUM -gt 0 ]; then - rate=$(echo "scale=1; $success * 100 / $NUM" | bc) - echo "Rate: ${rate}%" -fi -echo "Duration: ${ELAPSED}s" -echo "Parallel: $PARALLEL" - -exit 0 diff --git a/tools/tgcalls_cli/run-test.sh b/tools/tgcalls_cli/run-test.sh deleted file mode 100755 index cf75776ade..0000000000 --- a/tools/tgcalls_cli/run-test.sh +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env bash -# Launch N tgcalls test tasks on ECS Fargate, spread across reflectors. -# -# Usage: -# ./run-test.sh # 10 tasks, 30s each -# ./run-test.sh -n 100 # 100 tasks -# ./run-test.sh -n 50 -d 60 # 50 tasks, 60s each -# ./run-test.sh --results # fetch results from last run - -set -euo pipefail - -CLUSTER="tgcalls-test" -TASK_DEF="tgcalls-test" -REGION="eu-west-1" -SUBNETS="subnet-0292f49f3b4885428,subnet-09b8edab6eb20b837,subnet-0f464b5c62c9a6d1a" -SECURITY_GROUP="sg-0d87a1f19be76c160" -LOG_GROUP="/ecs/tgcalls-test" -REFLECTOR_URL="https://core.telegram.org/getReflectorList" -RUN_FILE="/tmp/tgcalls-last-run.txt" -STATUS_FILE="/tmp/tgcalls-last-status.txt" - -NUM_TASKS=10 -DURATION=30 - -usage() { - echo "Usage: $0 [-n NUM_TASKS] [-d DURATION_SECS] [--results]" - exit 1 -} - -fetch_results() { - if [ ! -f "$RUN_FILE" ]; then - echo "No run file found. Run a test first." - exit 1 - fi - - echo "Fetching results from last run..." - echo "" - - TMPDIR_RESULTS=$(mktemp -d) - RESULTS_PARALLEL=20 - total=$(wc -l < "$RUN_FILE" | tr -d ' ') - fetched=0 - - # Fetch logs in parallel - while IFS= read -r task_id; do - stream="tgcalls/tgcalls/${task_id}" - (aws logs get-log-events \ - --log-group-name "$LOG_GROUP" \ - --log-stream-name "$stream" \ - --region "$REGION" \ - --query 'events[*].message' \ - --output text > "${TMPDIR_RESULTS}/${task_id}" 2>/dev/null || true) & - - fetched=$((fetched + 1)) - # Throttle: wait every RESULTS_PARALLEL calls - if [ $((fetched % RESULTS_PARALLEL)) -eq 0 ]; then - wait - echo -ne " Fetched $fetched/$total\r" - fi - done < "$RUN_FILE" - wait - echo " Fetched $total/$total" - echo "" - - # Tally results - success=0 - fail=0 - errors="" - no_logs_tasks=() - - for result_file in "${TMPDIR_RESULTS}"/*; do - [ -f "$result_file" ] || continue - task_id=$(basename "$result_file") - output=$(cat "$result_file") - - if [ -z "$output" ]; then - # No logs yet — queue for retry - no_logs_tasks+=("$task_id") - elif echo "$output" | tr '\t' '\n' | grep -q "Audio received:.*yes" && echo "$output" | tr '\t' '\n' | grep -q "Call established:.*yes"; then - success=$((success + 1)) - else - fail=$((fail + 1)) - reflector=$(echo "$output" | tr '\t' '\n' | grep -o 'reflector ([^)]*' | sed 's/reflector (//' || echo "unknown") - errors="${errors}\n ${task_id}: reflector=${reflector}" - fi - done - - rm -rf "$TMPDIR_RESULTS" - - # Retry tasks that had no logs - if [ ${#no_logs_tasks[@]} -gt 0 ]; then - echo "Retrying ${#no_logs_tasks[@]} tasks with missing logs..." - sleep 5 - for task_id in "${no_logs_tasks[@]}"; do - stream="tgcalls/tgcalls/${task_id}" - output=$(aws logs get-log-events \ - --log-group-name "$LOG_GROUP" \ - --log-stream-name "$stream" \ - --region "$REGION" \ - --query 'events[*].message' \ - --output text 2>/dev/null || true) - - if [ -n "$output" ] && echo "$output" | tr '\t' '\n' | grep -q "Audio received:.*yes" && echo "$output" | tr '\t' '\n' | grep -q "Call established:.*yes"; then - success=$((success + 1)) - else - fail=$((fail + 1)) - ecs_info="" - if [ -f "$STATUS_FILE" ]; then - ecs_info=$(grep "^${task_id}" "$STATUS_FILE" | head -1 | cut -f2-3) - fi - if [ -n "$ecs_info" ]; then - errors="${errors}\n ${task_id}: exit=${ecs_info}" - else - errors="${errors}\n ${task_id} (no logs, no ECS status)" - fi - fi - done - echo "" - fi - - echo "=== Test Results ===" - echo "Total tasks: $total" - echo "Success: $success" - echo "Failed: $fail" - if [ -n "$errors" ]; then - echo -e "\nFailed tasks:${errors}" - fi - exit 0 -} - -# Parse args -while [[ $# -gt 0 ]]; do - case $1 in - -n) NUM_TASKS="$2"; shift 2 ;; - -d) DURATION="$2"; shift 2 ;; - --results) fetch_results ;; - *) usage ;; - esac -done - -# Fetch reflector list — IPs only (port randomized by CLI) -echo "Fetching reflector list..." -REFLECTOR_CSV=$(curl -s "$REFLECTOR_URL" | cut -d: -f1 | sort -u | tr '\n' ',' | sed 's/,$//') -NUM_REFLECTORS=$(echo "$REFLECTOR_CSV" | tr ',' '\n' | wc -l | tr -d ' ') -echo "Got $NUM_REFLECTORS unique reflector IPs" - -# To inject bad addresses for testing, uncomment: -# NUM_BAD=$(( NUM_REFLECTORS / 9 )) -# BAD_CSV=$(for i in $(seq 1 $NUM_BAD); do echo -n "10.255.255.$((i % 256)):1,"; done | sed 's/,$//') -# REFLECTOR_CSV="${REFLECTOR_CSV},${BAD_CSV}" -# echo "Injected $NUM_BAD bad addresses (~10% of pool)" - -echo "Launching $NUM_TASKS tasks (${DURATION}s each), each picks a random reflector..." -echo "" - -# Clear run files -> "$RUN_FILE" -> "$STATUS_FILE" - -# Launch in waves of WAVE_SIZE, waiting for each wave to complete before the next. -# Within each wave, fire PARALLEL API calls concurrently (each launching up to 10 tasks). -WAVE_SIZE=500 -PARALLEL=10 -TMPDIR_LAUNCH=$(mktemp -d) -remaining=$NUM_TASKS -wave=0 - -while [ $remaining -gt 0 ]; do - wave=$((wave + 1)) - wave_target=$((remaining > WAVE_SIZE ? WAVE_SIZE : remaining)) - wave_arns=() - wave_launched=0 - - echo "=== Wave $wave: launching $wave_target tasks ===" - - while [ $wave_launched -lt $wave_target ]; do - pids=() - api_calls=0 - for p in $(seq 1 $PARALLEL); do - left=$((wave_target - wave_launched - api_calls * 10)) - [ $left -le 0 ] && break - batch=$((left > 10 ? 10 : left)) - outfile="${TMPDIR_LAUNCH}/batch_${wave}_${wave_launched}_${p}" - api_calls=$((api_calls + 1)) - - (aws ecs run-task --region "$REGION" \ - --cluster "$CLUSTER" \ - --task-definition "$TASK_DEF" \ - --launch-type FARGATE \ - --count "$batch" \ - --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SECURITY_GROUP}],assignPublicIp=ENABLED}" \ - --overrides "{\"containerOverrides\":[{\"name\":\"tgcalls\",\"command\":[\"--quiet\",\"--reflector-list\",\"${REFLECTOR_CSV}\",\"--duration\",\"${DURATION}\",\"--drop-rate\",\"0.3\",\"--delay\",\"50-200\"]}]}" \ - --query 'tasks[*].taskArn' --output text > "$outfile" 2>&1) & - pids+=($!) - done - - for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true - done - - for outfile in "${TMPDIR_LAUNCH}"/batch_*; do - [ -f "$outfile" ] || continue - while read -r arn; do - if [[ "$arn" == arn:* ]]; then - task_id="${arn##*/}" - wave_arns+=("$arn") - echo "$task_id" >> "$RUN_FILE" - wave_launched=$((wave_launched + 1)) - fi - done < <(tr '\t' '\n' < "$outfile") - rm -f "$outfile" - done - - echo " Launched $wave_launched/$wave_target in wave $wave" - done - - remaining=$((remaining - wave_launched)) - echo " Waiting for wave $wave ($wave_launched tasks) to finish..." - - # Wait in batches of 100 - for ((start=0; start<${#wave_arns[@]}; start+=100)); do - batch=("${wave_arns[@]:$start:100}") - aws ecs wait tasks-stopped \ - --cluster "$CLUSTER" \ - --tasks "${batch[@]}" \ - --region "$REGION" 2>/dev/null || true - done - - # Collect ECS task status while data is fresh (expires after ~1hr) - echo " Collecting task status for wave $wave..." - for ((start=0; start<${#wave_arns[@]}; start+=100)); do - batch=("${wave_arns[@]:$start:100}") - aws ecs describe-tasks --cluster "$CLUSTER" --tasks "${batch[@]}" --region "$REGION" \ - --query 'tasks[*].[containers[0].taskArn,containers[0].exitCode,stoppedReason]' \ - --output text 2>/dev/null | while IFS=$'\t' read -r arn exit_code reason; do - task_id="${arn##*/}" - echo -e "${task_id}\t${exit_code}\t${reason}" >> "$STATUS_FILE" - done - done - - echo " Wave $wave complete." - echo "" -done - -rm -rf "$TMPDIR_LAUNCH" -total_launched=$(wc -l < "$RUN_FILE" | tr -d ' ') - -echo "Launched $total_launched/$NUM_TASKS total tasks." -echo "Run '$0 --results' to see results." diff --git a/tools/tgcalls_cli/run-until-crash.sh b/tools/tgcalls_cli/run-until-crash.sh deleted file mode 100755 index 0bf14ce74c..0000000000 --- a/tools/tgcalls_cli/run-until-crash.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# Run parallel tests, stop on first crash (non-zero exit). -set -euo pipefail - -BINARY="./bazel-bin/tools/tgcalls_cli/tgcalls_cli" -PARALLEL=250 -DURATION=15 -VERSION="11.0.0" -DROP_RATE=0.3 -DELAY="50-200" -MODE="p2p" - -while [[ $# -gt 0 ]]; do - case $1 in - -j) PARALLEL="$2"; shift 2 ;; - -d) DURATION="$2"; shift 2 ;; - --version) VERSION="$2"; shift 2 ;; - --drop-rate) DROP_RATE="$2"; shift 2 ;; - --delay) DELAY="$2"; shift 2 ;; - --mode) MODE="$2"; shift 2 ;; - *) echo "Usage: $0 [-j PARALLEL] [-d DURATION] [--version VER] [--drop-rate R] [--delay D] [--mode M]"; exit 1 ;; - esac -done - -if [ ! -x "$BINARY" ]; then - echo "Binary not found: $BINARY" - exit 1 -fi - -TMPDIR=$(mktemp -d) -trap "rm -rf $TMPDIR" EXIT - -echo "Running waves of $PARALLEL until first crash (${DURATION}s, drop=${DROP_RATE}, delay=${DELAY}, version=${VERSION})" - -wave=0 -total=0 -while true; do - wave=$((wave + 1)) - pids=() - for i in $(seq 1 $PARALLEL); do - id=$((total + i)) - ( - set +e - "$BINARY" --mode "$MODE" --duration "$DURATION" \ - --drop-rate "$DROP_RATE" --delay "$DELAY" --version "$VERSION" --quiet \ - > "$TMPDIR/${id}.out" 2>"$TMPDIR/${id}.err" - echo $? > "$TMPDIR/${id}.rc" - ) & - pids+=($!) - done - - for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true - done - - total=$((total + PARALLEL)) - - # Check for crashes - crashes=0 - for i in $(seq $((total - PARALLEL + 1)) $total); do - rc_file="$TMPDIR/${i}.rc" - if [ ! -f "$rc_file" ]; then - crashes=$((crashes + 1)) - echo "" - echo "=== CRASH in run $i (no rc file) ===" - echo "--- stderr ---" - cat "$TMPDIR/${i}.err" 2>/dev/null || echo "(empty)" - else - rc=$(cat "$rc_file") - if [ "$rc" -gt 128 ] 2>/dev/null; then - crashes=$((crashes + 1)) - echo "" - echo "=== CRASH in run $i (exit $rc) ===" - echo "--- stderr ---" - cat "$TMPDIR/${i}.err" 2>/dev/null || echo "(empty)" - echo "--- stdout ---" - cat "$TMPDIR/${i}.out" 2>/dev/null || echo "(empty)" - # Only show first crash in detail - if [ $crashes -eq 1 ]; then - echo "=== END CRASH ===" - fi - fi - fi - done - - if [ $crashes -gt 0 ]; then - echo "" - echo "Wave $wave: $crashes crashes in $PARALLEL runs (total $total runs)" - exit 1 - fi - - echo " Wave $wave: $PARALLEL/$PARALLEL passed (total $total)" -done From c63d2b115c732964349200ea7fb8c77d99e0d0c2 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 30 Apr 2026 22:22:56 +0200 Subject: [PATCH 50/69] Update submodules --- submodules/TgVoipWebrtc/tgcalls | 2 +- third-party/webrtc/webrtc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index 43d03b0b82..454718339e 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit 43d03b0b827be372fed73ad8a31405d5a74028a4 +Subproject commit 454718339e9b8c9dad7effcd94a3f5e534043537 diff --git a/third-party/webrtc/webrtc b/third-party/webrtc/webrtc index d5c77d3588..3817e906cb 160000 --- a/third-party/webrtc/webrtc +++ b/third-party/webrtc/webrtc @@ -1 +1 @@ -Subproject commit d5c77d3588c9353dd48b80430d2ffb41dafef177 +Subproject commit 3817e906cb6c22ec9cc62023b073e1a668d9cb33 From 43b0124790edbfcbbba86cbf4cc56a427d3fecec Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 1 May 2026 00:09:52 +0200 Subject: [PATCH 51/69] Fix poll countries limit --- .../Sources/CountriesMultiselectionScreen.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift index 3c3ad2573a..20f40edbe2 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -464,8 +464,7 @@ final class CountriesMultiselectionScreenComponent: Component { update() } - let limit = component.context.userLimits.maxGiveawayCountriesCount - if self.selectedCountries.count >= limit, index == nil { + if let limit = component.stateContext.maxCount, self.selectedCountries.count >= limit, index == nil { self.hapticFeedback.error() let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } From 2edd2ffea72eab029448f77368cc69c9ec8d216a Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 00:49:47 +0200 Subject: [PATCH 52/69] Update instructions and fix build --- CLAUDE.md | 13 +++++++++++++ MODULE.bazel.lock | 17 +++++++++++++++++ .../BroadcastUploadExtension.swift | 1 + .../Sources/GroupCallScreencast.swift | 1 + .../Sources/PresentationGroupCall.swift | 13 +++++++++---- .../TelegramVoip/Sources/GroupCallContext.swift | 11 +++++++---- .../OngoingCallThreadLocalContext.h | 3 ++- .../Sources/OngoingCallThreadLocalContext.mm | 13 ++++++++++--- third-party/webrtc/BUILD | 4 +++- 9 files changed, 63 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a6239dbda..3acd4a0c45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,3 +147,16 @@ All mediaBox methods with clean signatures (no Postbox-protocol leaks, no comple **Facade-shape convention:** all of these take `EngineMediaResource.Id` or `EngineMediaResource` (never raw `MediaResourceId`/`MediaResource`). Return types either don't leak Postbox (`Void`, `String`, `String?`, `Signal, NoError>`, `Signal`) or wrap via TelegramCore type (`Signal`). **Swift-stdlib-vs-third-party-module name collisions** (learned in wave 26): `RangeSet` collides with Swift stdlib's `RangeSet` (iOS 18+ only). Fix: `import RangeSet` at the file top of any TelegramCore file that names `RangeSet` in a signature. `TelegramCore/BUILD` already depends on `//submodules/Utils/RangeSet:RangeSet`. Future facade additions in TelegramEngineResources.swift should re-check this if new signature types are introduced. + +## tgcalls Testbench + +This repo includes a tgcalls testbench (CLI tool, Go/Pion SFU, Docker build) layered on top of the iOS source. All testbench code, build instructions, and architecture docs live inside the tgcalls submodule: + +- `submodules/TgVoipWebrtc/tgcalls/CLAUDE.md` — top-level testbench overview, build/run commands +- `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — CLI test tool architecture +- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` — Go SFU internals +- `submodules/TgVoipWebrtc/CLAUDE.md` — tgcalls library internals + macOS/Linux build patches + +Build the test binary from this directory with: + +`./build-input/bazel-8.4.2 build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli` diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7cb1fb2643..db07afeb11 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -13,6 +13,7 @@ "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/source.json": "d725d73707d01bb46ab3ca59ba408b8e9bd336642ca77a2269d4bfb8bbfd413d", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", @@ -46,6 +47,12 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0", + "https://bcr.bazel.build/modules/gazelle/0.43.0/MODULE.bazel": "846e1fe396eefc0f9ddad2b33e9bd364dd993fc2f42a88e31590fe0b0eefa3f0", + "https://bcr.bazel.build/modules/gazelle/0.43.0/source.json": "021a77f6625906d9d176e2fa351175e842622a5d45989312f2ad4924aab72df6", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", @@ -76,6 +83,8 @@ "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/MODULE.bazel": "74e541b0ba877813da786a11707d4e394433c157841d5111a36be0d44b907931", "https://bcr.bazel.build/modules/protobuf/34.0.bcr.1/source.json": "fc174b3d6215aa14197d1bd779f98bb72d9fd666ee5ec0d6bba6ae986baa4535", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", @@ -106,6 +115,12 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", + "https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0", + "https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb", + "https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", @@ -142,6 +157,7 @@ "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", @@ -171,6 +187,7 @@ "https://bcr.bazel.build/modules/swift_argument_parser/1.7.0/source.json": "b9b952cba0c748083b9b891e6ac46d347c92d37e8a92ead96d2a54b966bacd87", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" diff --git a/Telegram/BroadcastUpload/BroadcastUploadExtension.swift b/Telegram/BroadcastUpload/BroadcastUploadExtension.swift index e2ea000ca3..dd74867392 100644 --- a/Telegram/BroadcastUpload/BroadcastUploadExtension.swift +++ b/Telegram/BroadcastUpload/BroadcastUploadExtension.swift @@ -163,6 +163,7 @@ private final class EmbeddedBroadcastUploadImpl: BroadcastUploadImpl { enableNoiseSuppression: false, disableAudioInput: true, enableSystemMute: false, + useReferenceImpl: false, prioritizeVP8: false, logPath: "", onMutedSpeechActivityDetected: { _ in }, diff --git a/submodules/TelegramCallsUI/Sources/GroupCallScreencast.swift b/submodules/TelegramCallsUI/Sources/GroupCallScreencast.swift index 4e95083d02..b2ae2e0465 100644 --- a/submodules/TelegramCallsUI/Sources/GroupCallScreencast.swift +++ b/submodules/TelegramCallsUI/Sources/GroupCallScreencast.swift @@ -86,6 +86,7 @@ final class ScreencastInProcessIPCContext: ScreencastIPCContext { enableNoiseSuppression: false, disableAudioInput: true, enableSystemMute: false, + useReferenceImpl: false, prioritizeVP8: false, logPath: "", onMutedSpeechActivityDetected: { _ in }, diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index 25916cfe2f..501ee547dd 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -1856,12 +1856,17 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { } var prioritizeVP8 = false - #if DEBUG && false - prioritizeVP8 = "".isEmpty - #endif if let data = self.accountContext.currentAppConfiguration.with({ $0 }).data, let value = data["ios_calls_prioritize_vp8"] as? Double { prioritizeVP8 = value != 0.0 } + + var useReferenceImpl = false + #if DEBUG && true + useReferenceImpl = "".isEmpty + #endif + if let data = self.accountContext.currentAppConfiguration.with({ $0 }).data, let value = data["ios_calls_group_reference_impl"] as? Double { + useReferenceImpl = value != 0.0 + } genericCallContext = .call(OngoingGroupCallContext(audioSessionActive: contextAudioSessionActive, video: self.videoCapturer, requestMediaChannelDescriptions: { [weak self] ssrcs, completion in let disposable = MetaDisposable() @@ -1881,7 +1886,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { self.requestCall(movingFromBroadcastToRtc: false) } } - }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, enableSystemMute: self.accountContext.sharedContext.immediateExperimentalUISettings.experimentalCallMute, prioritizeVP8: prioritizeVP8, logPath: allocateCallLogPath(account: self.account), onMutedSpeechActivityDetected: { [weak self] value in + }, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit,videoContentType: self.isVideoEnabled ? .generic : .none, enableNoiseSuppression: false, disableAudioInput: self.isStream, enableSystemMute: self.accountContext.sharedContext.immediateExperimentalUISettings.experimentalCallMute, useReferenceImpl: useReferenceImpl, prioritizeVP8: prioritizeVP8, logPath: allocateCallLogPath(account: self.account), onMutedSpeechActivityDetected: { [weak self] value in Queue.mainQueue().async { guard let self else { return diff --git a/submodules/TelegramVoip/Sources/GroupCallContext.swift b/submodules/TelegramVoip/Sources/GroupCallContext.swift index 59f69e97fd..41e2e3d4bb 100644 --- a/submodules/TelegramVoip/Sources/GroupCallContext.swift +++ b/submodules/TelegramVoip/Sources/GroupCallContext.swift @@ -512,6 +512,7 @@ public final class OngoingGroupCallContext { enableNoiseSuppression: Bool, disableAudioInput: Bool, enableSystemMute: Bool, + useReferenceImpl: Bool, prioritizeVP8: Bool, logPath: String, onMutedSpeechActivityDetected: @escaping (Bool) -> Void, @@ -663,7 +664,8 @@ public final class OngoingGroupCallContext { return encryptionContext.decrypt(message: data, userId: userId) } } - } + }, + useReferenceImpl: useReferenceImpl ) #else self.context = GroupCallThreadLocalContext( @@ -773,7 +775,8 @@ public final class OngoingGroupCallContext { return encryptionContext.decrypt(message: data, userId: userId) } } - } + }, + useReferenceImpl: useReferenceImpl ) #endif @@ -1223,10 +1226,10 @@ public final class OngoingGroupCallContext { } } - public init(inputDeviceId: String = "", outputDeviceId: String = "", audioSessionActive: Signal, video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, disableAudioInput: Bool, enableSystemMute: Bool, prioritizeVP8: Bool, logPath: String, onMutedSpeechActivityDetected: @escaping (Bool) -> Void, isConference: Bool, audioIsActiveByDefault: Bool, isStream: Bool, sharedAudioDevice: OngoingCallContext.AudioDevice?, encryptionContext: OngoingGroupCallEncryptionContext?) { + public init(inputDeviceId: String = "", outputDeviceId: String = "", audioSessionActive: Signal, video: OngoingCallVideoCapturer?, requestMediaChannelDescriptions: @escaping (Set, @escaping ([MediaChannelDescription]) -> Void) -> Disposable, rejoinNeeded: @escaping () -> Void, outgoingAudioBitrateKbit: Int32?, videoContentType: VideoContentType, enableNoiseSuppression: Bool, disableAudioInput: Bool, enableSystemMute: Bool, useReferenceImpl: Bool, prioritizeVP8: Bool, logPath: String, onMutedSpeechActivityDetected: @escaping (Bool) -> Void, isConference: Bool, audioIsActiveByDefault: Bool, isStream: Bool, sharedAudioDevice: OngoingCallContext.AudioDevice?, encryptionContext: OngoingGroupCallEncryptionContext?) { let queue = self.queue self.impl = QueueLocalObject(queue: queue, generate: { - return Impl(queue: queue, inputDeviceId: inputDeviceId, outputDeviceId: outputDeviceId, audioSessionActive: audioSessionActive, video: video, requestMediaChannelDescriptions: requestMediaChannelDescriptions, rejoinNeeded: rejoinNeeded, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: videoContentType, enableNoiseSuppression: enableNoiseSuppression, disableAudioInput: disableAudioInput, enableSystemMute: enableSystemMute, prioritizeVP8: prioritizeVP8, logPath: logPath, onMutedSpeechActivityDetected: onMutedSpeechActivityDetected, isConference: isConference, audioIsActiveByDefault: audioIsActiveByDefault, isStream: isStream, sharedAudioDevice: sharedAudioDevice, encryptionContext: encryptionContext) + return Impl(queue: queue, inputDeviceId: inputDeviceId, outputDeviceId: outputDeviceId, audioSessionActive: audioSessionActive, video: video, requestMediaChannelDescriptions: requestMediaChannelDescriptions, rejoinNeeded: rejoinNeeded, outgoingAudioBitrateKbit: outgoingAudioBitrateKbit, videoContentType: videoContentType, enableNoiseSuppression: enableNoiseSuppression, disableAudioInput: disableAudioInput, enableSystemMute: enableSystemMute, useReferenceImpl: useReferenceImpl, prioritizeVP8: prioritizeVP8, logPath: logPath, onMutedSpeechActivityDetected: onMutedSpeechActivityDetected, isConference: isConference, audioIsActiveByDefault: audioIsActiveByDefault, isStream: isStream, sharedAudioDevice: sharedAudioDevice, encryptionContext: encryptionContext) }) } diff --git a/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h index 67406bd78d..5b51961b9f 100644 --- a/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h +++ b/submodules/TgVoipWebrtc/PublicHeaders/TgVoipWebrtc/OngoingCallThreadLocalContext.h @@ -458,7 +458,8 @@ onMutedSpeechActivityDetected:(void (^ _Nullable)(bool))onMutedSpeechActivityDet audioDevice:(SharedCallAudioDevice * _Nullable)audioDevice isConference:(bool)isConference isActiveByDefault:(bool)isActiveByDefault -encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, bool, int32_t))encryptDecrypt; +encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, bool, int32_t))encryptDecrypt +useReferenceImpl:(bool)useReferenceImpl; - (void)stop:(void (^ _Nullable)())completion; diff --git a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm index 5e912dbcaa..5f67764d10 100644 --- a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm +++ b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm @@ -34,6 +34,7 @@ #import "group/GroupInstanceImpl.h" #import "group/GroupInstanceCustomImpl.h" +#import "group/GroupInstanceReferenceImpl.h" #import "VideoCaptureInterfaceImpl.h" @@ -2388,7 +2389,8 @@ onMutedSpeechActivityDetected:(void (^ _Nullable)(bool))onMutedSpeechActivityDet audioDevice:(SharedCallAudioDevice * _Nullable)audioDevice isConference:(bool)isConference isActiveByDefault:(bool)isActiveByDefault -encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, bool, int32_t))encryptDecrypt { +encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, bool, int32_t))encryptDecrypt +useReferenceImpl:(bool)useReferenceImpl { self = [super init]; if (self != nil) { _queue = queue; @@ -2471,7 +2473,7 @@ encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, boo } __weak GroupCallThreadLocalContext *weakSelf = self; - _instance.reset(new tgcalls::GroupInstanceCustomImpl((tgcalls::GroupInstanceDescriptor){ + tgcalls::GroupInstanceDescriptor descriptor = (tgcalls::GroupInstanceDescriptor){ .threads = tgcalls::StaticThreads::getThreads(), .config = config, .statsLogPath = statsLogPathValue, @@ -2699,7 +2701,12 @@ encryptDecrypt:(NSData * _Nullable (^ _Nullable)(NSData * _Nonnull, int64_t, boo }, .e2eEncryptDecrypt = mappedEncryptDecrypt, .isConference = isConference - })); + }; + if (useReferenceImpl) { + _instance.reset(new tgcalls::GroupInstanceReferenceImpl(std::move(descriptor))); + } else { + _instance.reset(new tgcalls::GroupInstanceCustomImpl(std::move(descriptor))); + } } return self; } diff --git a/third-party/webrtc/BUILD b/third-party/webrtc/BUILD index 954e853949..2fcf1867a2 100644 --- a/third-party/webrtc/BUILD +++ b/third-party/webrtc/BUILD @@ -3227,10 +3227,12 @@ cc_library( "-framework CoreVideo", "-framework CoreGraphics", "-framework QuartzCore", - "-framework AppKit", "-weak_framework Network", "-weak_framework Metal", ], + }) + select({ + "@platforms//os:macos": ["-framework AppKit"], + "//conditions:default": [], }), visibility = ["//visibility:public"], ) From 2481687329e7c51c40f85d4e1dd4885f7dd86154 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 1 May 2026 15:08:48 +0200 Subject: [PATCH 53/69] Add auth product --- .../InAppPurchaseManager/Sources/InAppPurchaseManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 74f3824243..23ea3eb290 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -28,6 +28,7 @@ private let productIdentifiers = [ "org.telegram.telegramPremium.twelveMonths.code_x10", "org.telegram.telegramPremium.oneWeek.auth", + "org.telegram.telegramPremium.threeDays.auth", "org.telegram.telegramStars.topup.x15", "org.telegram.telegramStars.topup.x25", From 533a179131c8b0634a22f10fe897817ecbe8529d Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 1 May 2026 15:28:26 +0200 Subject: [PATCH 54/69] Auth payment improvements --- .../AuthorizationSequencePaymentScreen.swift | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift index 8658893b75..ddb76da69a 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift @@ -28,6 +28,7 @@ import PhoneNumberFormat import PlainButtonComponent import StoreKit import DeviceModel +import GlassBarButtonComponent final class AuthorizationSequencePaymentScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -254,25 +255,27 @@ final class AuthorizationSequencePaymentScreenComponent: Component { let helpButtonSize = self.helpButton.update( transition: transition, - component: AnyComponent(PlainButtonComponent( - content: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: environment.strings.Login_PhoneNumberHelp, font: Font.regular(17.0), textColor: environment.theme.list.itemAccentColor)) - )), - minSize: CGSize(width: 0.0, height: 44.0), - contentInsets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0), - action: { [weak self] in - guard let self else { - return + component: AnyComponent( + GlassBarButtonComponent( + size: nil, + backgroundColor: nil, + isDark: environment.theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "label", component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Login_PhoneNumberHelp, font: Font.regular(17.0), textColor: environment.theme.chat.inputPanel.panelControlColor)) + ))), + action: { [weak self] _ in + guard let self else { + return + } + self.displaySendEmail(error: nil, errorCode: nil) } - self.displaySendEmail(error: nil, errorCode: nil) - }, - animateScale: false, - animateContents: false - )), + ) + ), environment: {}, - containerSize: CGSize(width: 200.0, height: 100.0) + containerSize: CGSize(width: 200.0, height: 44.0) ) - let helpButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - 8.0 - helpButtonSize.width, y: environment.statusBarHeight), size: helpButtonSize) + let helpButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - helpButtonSize.width, y: environment.navigationHeight - helpButtonSize.height - 6.0), size: helpButtonSize) if let helpButtonView = self.helpButton.view { if helpButtonView.superview == nil { self.addSubview(helpButtonView) From ffd82647ee97c68e4da4802fe9717ccccd8105c9 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 1 May 2026 15:29:18 +0200 Subject: [PATCH 55/69] Various fixes --- .../AuthorizationSequencePaymentScreen.swift | 35 ++++++++++--------- .../Sources/InAppPurchaseManager.swift | 1 + .../CountriesMultiselectionScreen.swift | 3 +- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift index 8658893b75..ddb76da69a 100644 --- a/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift +++ b/submodules/AuthorizationUI/Sources/AuthorizationSequencePaymentScreen.swift @@ -28,6 +28,7 @@ import PhoneNumberFormat import PlainButtonComponent import StoreKit import DeviceModel +import GlassBarButtonComponent final class AuthorizationSequencePaymentScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -254,25 +255,27 @@ final class AuthorizationSequencePaymentScreenComponent: Component { let helpButtonSize = self.helpButton.update( transition: transition, - component: AnyComponent(PlainButtonComponent( - content: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: environment.strings.Login_PhoneNumberHelp, font: Font.regular(17.0), textColor: environment.theme.list.itemAccentColor)) - )), - minSize: CGSize(width: 0.0, height: 44.0), - contentInsets: UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0), - action: { [weak self] in - guard let self else { - return + component: AnyComponent( + GlassBarButtonComponent( + size: nil, + backgroundColor: nil, + isDark: environment.theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "label", component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: environment.strings.Login_PhoneNumberHelp, font: Font.regular(17.0), textColor: environment.theme.chat.inputPanel.panelControlColor)) + ))), + action: { [weak self] _ in + guard let self else { + return + } + self.displaySendEmail(error: nil, errorCode: nil) } - self.displaySendEmail(error: nil, errorCode: nil) - }, - animateScale: false, - animateContents: false - )), + ) + ), environment: {}, - containerSize: CGSize(width: 200.0, height: 100.0) + containerSize: CGSize(width: 200.0, height: 44.0) ) - let helpButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - 8.0 - helpButtonSize.width, y: environment.statusBarHeight), size: helpButtonSize) + let helpButtonFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - helpButtonSize.width, y: environment.navigationHeight - helpButtonSize.height - 6.0), size: helpButtonSize) if let helpButtonView = self.helpButton.view { if helpButtonView.superview == nil { self.addSubview(helpButtonView) diff --git a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift index 74f3824243..23ea3eb290 100644 --- a/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift +++ b/submodules/InAppPurchaseManager/Sources/InAppPurchaseManager.swift @@ -28,6 +28,7 @@ private let productIdentifiers = [ "org.telegram.telegramPremium.twelveMonths.code_x10", "org.telegram.telegramPremium.oneWeek.auth", + "org.telegram.telegramPremium.threeDays.auth", "org.telegram.telegramStars.topup.x15", "org.telegram.telegramStars.topup.x25", diff --git a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift index 3c3ad2573a..20f40edbe2 100644 --- a/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift +++ b/submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/CountriesMultiselectionScreen.swift @@ -464,8 +464,7 @@ final class CountriesMultiselectionScreenComponent: Component { update() } - let limit = component.context.userLimits.maxGiveawayCountriesCount - if self.selectedCountries.count >= limit, index == nil { + if let limit = component.stateContext.maxCount, self.selectedCountries.count >= limit, index == nil { self.hapticFeedback.error() let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } From 4406fc62394cfaeb02c33d842771ced83d8deb6e Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:06:14 +0200 Subject: [PATCH 56/69] Spec: ListView pin-to-edge half-area cap Cover clipping behavior and bottom-inset overflow. --- ...01-listview-pin-to-edge-half-cap-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md diff --git a/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md b/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md new file mode 100644 index 0000000000..65ee2fcc5a --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md @@ -0,0 +1,123 @@ +# ListView pin-to-edge half-area cap — design + +## Problem + +In `ListViewImpl` (`submodules/Display/Source/ListView.swift`), an item that opts into `pinToEdgeWithInset` is anchored with its `apparentFrame.maxY` at `visibleSize.height − insets.bottom`. When the pinned item is taller than the available area, it dominates the entire visible region — its top extends above the top of the visible area, hiding adjacent content. + +The intended UX is that a pinned item never occupies more than half of the visible area, so context above (in list coords) / below (visually, in rotated chats) the pinned item remains visible. + +## Goal + +When the pinned item's height exceeds `halfArea = (visibleSize.height − insets.top − insets.bottom) / 2`, cap the pinned item's *visible portion* at `halfArea`. The remaining `pinnedHeight − halfArea` extends below the content area (between `visibleSize − insets.bottom` and `visibleSize`), into the bottom-inset region. The list view has `clipsToBounds = true` (line 498), so anything beyond `visibleSize.height` is clipped; in typical usage the bottom-inset region is occluded by overlay UI (chat input panel, tab bar) drawn on top of the list view. + +When the pinned item's height is `≤ halfArea`, behavior is identical to the current implementation. + +## Non-goals + +- No new public API on `ListView` or `ListViewItem`. The cap fraction is `0.5`, hard-coded. +- No changes to consumers (`ChatMessageItemImpl`, etc.). +- No changes to header/accessory layout. + +## Approach + +A single private helper computes the *bottom extension* — the distance by which the pinned item's bottom edge is pushed below `visibleSize − bottom`: + +``` +extension = max(0, pinnedHeight − halfArea) +``` + +When `extension > 0`, the pinned item's `apparentFrame.maxY` is anchored at `visibleSize − bottom + extension`, so its top edge sits at `visibleSize − bottom − halfArea`. The visible portion is exactly `halfArea`. + +Three existing sites in `ListView.swift` need to agree on the new anchor; all three use the same helper. + +### Helper + +Add to `ListViewImpl`: + +```swift +private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat { + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + let halfArea = visibleArea * 0.5 + guard halfArea > 0.0 else { return 0.0 } + return max(0.0, pinnedHeight - halfArea) +} +``` + +### Call site 1 — `calculatePinToEdgeTopInset()` (around line 1094) + +The pinned item's contribution to `totalAboveAndPinned` is capped at `halfArea`. This grows `pinToEdgeTopInset` when the pinned item is too tall, pushing items-above further down so their stack lines up with the new (lowered) pinned-item top edge. + +```swift +let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom +let halfArea = visibleArea * 0.5 + +var totalAboveAndPinned: CGFloat = 0.0 +var sawIndexZero = false +for itemNode in self.itemNodes { + guard let index = itemNode.index else { continue } + if index == 0 { sawIndexZero = true } + if index < lowestPinnedIndex { + totalAboveAndPinned += itemNode.apparentBounds.height + } else if index == lowestPinnedIndex { + let pinnedHeight = itemNode.apparentBounds.height + let effectivePinnedHeight = halfArea > 0.0 ? min(pinnedHeight, halfArea) : pinnedHeight + totalAboveAndPinned += effectivePinnedHeight + } +} +guard sawIndexZero else { return 0.0 } +return max(0.0, visibleArea - totalAboveAndPinned) +``` + +### Call site 2 — pin-to-edge-target scroll offset (around line 3127) + +```swift +if isPinToEdgeTarget { + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom +} +``` + +### Call site 3 — `isStrictlyScrolledToPinToEdgeItem()` (around line 2683) + +```swift +for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } +} +``` + +All three sites read `apparentBounds.height` for consistency with the rest of the file (which uses post-layout, post-animation heights for pinning math). + +## Edge cases + +- **`pinnedHeight ≤ halfArea`**: helper returns 0, all three sites compute identical values to the current implementation. No behavioural change in the common case. +- **Multiple items with `pinToEdgeWithInset == true`**: existing semantics select the smallest-indexed one as `lowestPinnedIndex`. The cap applies only to that item. Others sit above as normal items. +- **`visibleSize.height ≤ insets.top + insets.bottom`** (degenerate / unmeasured layout): `halfArea ≤ 0`, helper returns 0, no cap. +- **Inset / size changes**: `pinToEdgeTopInset` and the scroll offset are recomputed on every `snapToBounds` / `replayOperations`, so the cap re-evaluates whenever inputs change. +- **Rotated chats (the actual usage path)**: list-coord-bottom maps to top-of-screen; capping the pinned item's visible portion at `halfArea` in list coords keeps the start of the pinned message visible at the top of the screen with the rest extending above. + +## Verification + +No unit tests exist for ListView in this project (per CLAUDE.md). Verification is: + +1. **Full build** with `--continueOnError`: + ``` + source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent \ + --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError + ``` +2. **Manual smoke test** in a chat with a pinned message: + - Short pinned message (`< halfArea`): unchanged behavior — pinned message anchored at top of screen, list scrolls under it. + - Mid-height pinned message (just under `halfArea`): unchanged. + - Tall pinned message (much taller than `halfArea`): only ~half of the visible area shows the pinned message; the other half remains available for the chat thread. + +## Risks + +- The pinned item's bottom extends past `visibleSize.height − insets.bottom` when capped, into the bottom-inset region of the list view's bounds. In contexts where the bottom-inset region is *not* covered by overlay UI (e.g., a list with `insets.bottom = 0`, or a list with bottom inset reserved for spacing rather than for an overlay), the overflow tail of the pinned item is briefly visible above the bottom edge of the list view. For the primary consumer (chat with `pinToTop` messages), the input panel is drawn on top and occludes the region. Confirm during manual smoke test on non-chat consumers if any. +- The cap is half hard-coded. If a future feature wants per-item or per-list configuration, this becomes a public knob; for now keep it private. From 4c3437b5754198f43bb150a65eb1226521eb4148 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:07:24 +0200 Subject: [PATCH 57/69] Add spec: instant-page link handling in rich-data bubble Wire URL tap detection, link-highlight feedback, and item-callback routing in ChatMessageRichDataBubbleContentNode, with stubbed intra-page anchor handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ubble-instant-page-link-handling-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md diff --git a/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md b/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md new file mode 100644 index 0000000000..5e946f94ac --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md @@ -0,0 +1,180 @@ +# Instant-page link handling in `ChatMessageRichDataBubbleContentNode` + +## Context + +`ChatMessageRichDataBubbleContentNode` renders a webpage's `instantPage` inline inside a chat bubble, by reusing the same `InstantPageLayout`/`InstantPageTile`/`InstantPageNode` machinery the full-screen instant view uses. Today the layout, tiles, and item nodes are wired up correctly, but every interactive callback (`openUrl`, `openPeer`, `openMedia`, …) on the realized item nodes is a commented stub, and `tapActionAtPoint` always returns `.none`. As a result, taps on URLs inside the inline preview do nothing. + +The full-screen instant view (`submodules/InstantPageUI/Sources/InstantPageControllerNode.swift`) handles URL taps by walking the layout to find the `InstantPageTextItem` under the tap location, asking it for `urlAttribute(at:)`, and then routing the resulting `InstantPageUrlItem` through its own `openUrl(_:)` resolver. Same-page anchors are handled inline via `scrollToAnchor(_:)`. + +Chat text bubbles handle URL taps via `ChatMessageBubbleContentTapAction(content: .url(...), rects:, activate:)`. The `activate` closure returns a `Promise` driven by upstream URL resolution; while it's `true` the bubble shows a `LinkHighlightingNode` overlay so users get press-feedback. + +## Goal + +Wire URL tap handling and link-highlight feedback into `ChatMessageRichDataBubbleContentNode`, plus stubbed handlers for intra-page anchor scrolling. Item-level `openUrl`/`openPeer` callbacks emitted by realized `InstantPageNode`s also route to the chat's `controllerInteraction`. + +Out of scope: media taps, pinch preview, embed height updates, details expansion, long-press action-sheet (Open / Copy / Add to Reading List), and the actual implementation of intra-page anchor scrolling — these stay as no-op stubs and can land as follow-ups. + +## Design + +### File touched + +`submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (only). + +### New private state + +``` +private var linkProgressDisposable: Disposable? +private var linkProgressRects: [CGRect]? +private var linkHighlightingNode: LinkHighlightingNode? +``` + +`deinit` disposes `linkProgressDisposable`. + +### Tap detection + +Two private helpers, modelled on `InstantPageControllerNode`: + +``` +private func textItemAtLocation(_ point: CGPoint) -> (InstantPageTextItem, CGPoint)? +private func urlForTapLocation(_ point: CGPoint) + -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, localPoint: CGPoint)? +``` + +- The incoming `point` is in the bubble-content-node coordinate system. The helpers subtract the `containerNode` offset `(1.0, 1.0)` once on entry, then walk `currentPageLayout?.layout.items`. +- Top-level `InstantPageTextItem`s, `InstantPageScrollableItem` (delegates to its own `textItemAtLocation` accounting for content offset), and `InstantPageDetailsItem` (looks up the realized `InstantPageDetailsNode` via `visibleItemsWithNodes` and queries its nested layout) are all supported — same coverage as the IV. +- `urlForTapLocation` calls `item.urlAttribute(at:)`. Returns the matched item, the `InstantPageUrlItem`, and the item-local point. The local point is what `linkSelectionRects(at:)` consumes when computing highlight rects. + +### `tapActionAtPoint` body + +Skeleton (existing `messageOptions` early-return is preserved): + +``` +override public func tapActionAtPoint(...) -> ChatMessageBubbleContentTapAction { + if case .tap = gesture { + } else { + if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { + return ChatMessageBubbleContentTapAction(content: .none) + } + } + + guard let urlHit = self.urlForTapLocation(point) else { + return ChatMessageBubbleContentTapAction(content: .none) + } + + let (baseUrl, anchor) = splitAnchor(urlHit.urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == baseUrl, let anchor { + return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in + self?.scrollToAnchor(anchor) + })) + } + + let concealed = true // see "Concealed flag" note below + let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) + let rects = self.computeHighlightRects(item: urlHit.item, localPoint: urlHit.localPoint) + return ChatMessageBubbleContentTapAction( + content: .url(url), + rects: rects, + activate: self.makeActivate(item: urlHit.item, localPoint: urlHit.localPoint) + ) +} +``` + +**Concealed flag**: default to `concealed = true` for v1. Reason: `InstantPageTextItem` does not expose a clean "attribute substring with range" API the way the chat text node does, so we cannot easily compare displayed link text to its target URL. `true` is the safer (more disclosure) default — chat will show a confirmation if the visible text and resolved URL differ. If during implementation a clean substring path emerges, switch to `doesUrlMatchText(url:text:fullText:)` analogously to text-bubble. + +### Highlight feedback + +`makeActivate(item:localPoint:)` mirrors the text-bubble pattern: + +``` +private func makeActivate(item: InstantPageTextItem, localPoint: CGPoint) -> (() -> Promise?)? { + return { [weak self] in + guard let self else { return nil } + let promise = Promise() + self.linkProgressDisposable?.dispose() + if self.linkProgressRects != nil { + self.linkProgressRects = nil + self.updateLinkProgressState() + } + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { return } + let updated: [CGRect]? = value + ? self.computeHighlightRects(item: item, localPoint: localPoint) + : nil + if self.linkProgressRects != updated { + self.linkProgressRects = updated + self.updateLinkProgressState() + } + }) + return promise + } +} +``` + +`computeHighlightRects(item:localPoint:)`: +- Calls `item.linkSelectionRects(at: localPoint)` — already public on `InstantPageTextItem`, returns the URL run's line rects in item-local coords. +- Translates each rect into `containerNode`-local coords by adding `item.frame.origin` plus any parent offset captured at hit-test time (zero for top-level items; the offset returned by `textItemAtLocation` for items nested under scrollables/details). + +`updateLinkProgressState()`: +- If `linkProgressRects` is non-nil and non-empty: lazily create `linkHighlightingNode` (`LinkHighlightingNode(color: incoming-or-outgoing linkHighlightColor)` derived from `self.item?.message.effectivelyIncoming(...)`), inserted into `containerNode` at index 0 (below all tiles). Set its frame to `containerNode.bounds`. Call `updateRects(rects)`. +- Otherwise: fade out the existing `linkHighlightingNode` (alpha 1→0 over 0.18s, remove on completion) and clear the field. + +Insertion order: rich-bubble tiles use `backgroundColor: .clear`, so a highlighting node positioned below them is visible through. Tiles are added with `insertSubnode(_, at: 0)` / `aboveSubnode:` — inserting the highlight at index 0 keeps it underneath every tile but inside the same `containerNode` clip region. + +### Item-callback wiring (inside `item.node(...)`) + +The currently stubbed callbacks become: + +``` +openMedia: { _ in /* TODO */ }, +longPressMedia: { _ in /* TODO */ }, +activatePinchPreview: { _ in /* TODO */ }, +pinchPreviewFinished: { _ in /* TODO */ }, +openPeer: { [weak self] peer in + guard let self, let item = self.item else { return } + item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) +}, +openUrl: { [weak self] urlItem in + guard let self, let item = self.item else { return } + let (baseUrl, anchor) = splitAnchor(urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == baseUrl, let anchor { + self.scrollToAnchor(anchor) + return + } + item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( + url: urlItem.url, + concealed: false, + message: item.message, + allowInlineWebpageResolution: urlItem.webpageId != nil + )) +}, +updateWebEmbedHeight: { _ in }, +updateDetailsExpanded: { _ in }, +``` + +- `openPeer` matches the IV's default routing — open the chat for the peer. +- `openUrl` honors the same-page-anchor stub, so item-emitted URL taps share the placeholder hook with text-tap routing. +- `urlItem.webpageId != nil` is mapped to `allowInlineWebpageResolution`. `InstantPageUrlItem.webpageId` is the IV's hint that the URL was authored as a referenced webpage, which is the same intent the chat flag captures. + +### Helpers + +``` +private func splitAnchor(_ url: String) -> (base: String, anchor: String?) +private func currentLoadedWebpage() -> TelegramMediaWebpageLoadedContent? +private func scrollToAnchor(_ anchor: String) { + // TODO: implement intra-page anchor scrolling +} +``` + +`splitAnchor` extracts the `#fragment` from a URL using the same approach as `InstantPageControllerNode.openUrl` (find `#`, percent-decode the suffix, slice the prefix). `currentLoadedWebpage` pulls `case .Loaded(content)` out of the first `TelegramMediaWebpage` on `self.item?.message.media`. + +## Verification + +- Build the app with `python3 build-system/Make/Make.py … build … --configuration=debug_sim_arm64` (no unit tests in this project). +- Manual test: send a message containing a t.me link with an instant-view preview. Tap a URL inside the rich-data preview bubble — it should route to the chat's URL handler (open inline webview / external browser / peer chat as appropriate). Long-press should fall through to the existing chat URL long-press menu (the bubble framework provides this for `.url` taps with `hasLongTapAction: true`, the default). Tapping a same-page anchor in the preview should hit the empty `scrollToAnchor` stub (no-op for now). +- Visual: while a URL is resolving, the URL run should be highlighted with a `LinkHighlightingNode` rectangle in the bubble's link-highlight color. The highlight should fade out on completion or cancellation. + +## Open follow-ups (not in this spec) + +- Implement `scrollToAnchor` (likely "open the full instant view at this anchor", since the inline rich bubble has no scroll view). +- Wire `openMedia` / `longPressMedia` / `activatePinchPreview` / `updateDetailsExpanded` / `updateWebEmbedHeight`. +- Long-press action sheet (Open / Copy / Add to Reading List) for URLs inside the inline preview, mirroring the IV. From c256a9eb17c1a5e374684bc92fb65dca0549dafb Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:10:04 +0200 Subject: [PATCH 58/69] Add plan: ListView pin-to-edge half-area cap Implementation plan for the spec from docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-01-listview-pin-to-edge-half-cap.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md diff --git a/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md b/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md new file mode 100644 index 0000000000..f0ae017a71 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md @@ -0,0 +1,262 @@ +# ListView pin-to-edge half-area cap — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cap the visible portion of a `pinToEdgeWithInset` item in `ListViewImpl` at half the visible area, so a tall pinned item can't take over the whole screen. + +**Architecture:** Add a single private helper on `ListViewImpl` that returns the *bottom extension* (`max(0, pinnedHeight − halfArea)`). Three existing sites in `ListView.swift` consume the extension to (1) cap the pinned-item contribution to `pinToEdgeTopInset`, (2) lower the pin-to-edge-target scroll anchor, and (3) match the new anchor in `isStrictlyScrolledToPinToEdgeItem`. When `extension == 0`, all three sites compute exactly what they compute today. + +**Tech Stack:** Swift, Bazel build via `Make.py`. No tests in this project (per CLAUDE.md) — verification is full build + manual smoke. + +**Spec:** `docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md` + +--- + +## File Structure + +All edits in one file: `submodules/Display/Source/ListView.swift`. + +| Site | Lines (current) | Change | +|------|-----------------|--------| +| Helper (new) | inserted right after `calculatePinToEdgeTopInset` | new private method `pinToEdgeBottomExtension(forPinnedHeight:)` | +| `calculatePinToEdgeTopInset` | 1094–1119 | cap the pinned item's contribution to `totalAboveAndPinned` at `halfArea` | +| `isStrictlyScrolledToPinToEdgeItem` | 2683 | use `extension` to compute `expectedMaxY` | +| pin-to-edge-target offset | 3127 | use `extension` to compute the target offset | + +All four edits must land in a single commit — committing one without the others leaves the inset calculation and the actual anchor inconsistent. + +--- + +### Task 1: Apply the four edits to `ListView.swift` + +**Files:** +- Modify: `submodules/Display/Source/ListView.swift` (lines 1094–1119, 2683, 3127, plus new helper) + +- [ ] **Step 1: Update `calculatePinToEdgeTopInset()` to cap the pinned item's contribution** + +Find the existing function (currently lines 1094–1119): + +```swift + private func calculatePinToEdgeTopInset() -> CGFloat { + var lowestPinnedIndex: Int = Int.max + for itemNode in self.itemNodes { + guard let index = itemNode.index, index >= 0, index < self.items.count else { continue } + if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { + lowestPinnedIndex = index + } + } + guard lowestPinnedIndex != Int.max else { return 0.0 } + + var totalAboveAndPinned: CGFloat = 0.0 + var sawIndexZero = false + for itemNode in self.itemNodes { + guard let index = itemNode.index else { continue } + if index == 0 { + sawIndexZero = true + } + if index <= lowestPinnedIndex { + totalAboveAndPinned += itemNode.apparentBounds.height + } + } + guard sawIndexZero else { return 0.0 } + + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + return max(0.0, visibleArea - totalAboveAndPinned) + } +``` + +Replace with: + +```swift + private func calculatePinToEdgeTopInset() -> CGFloat { + var lowestPinnedIndex: Int = Int.max + for itemNode in self.itemNodes { + guard let index = itemNode.index, index >= 0, index < self.items.count else { continue } + if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { + lowestPinnedIndex = index + } + } + guard lowestPinnedIndex != Int.max else { return 0.0 } + + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + let halfArea = visibleArea * 0.5 + + var totalAboveAndPinned: CGFloat = 0.0 + var sawIndexZero = false + for itemNode in self.itemNodes { + guard let index = itemNode.index else { continue } + if index == 0 { + sawIndexZero = true + } + if index < lowestPinnedIndex { + totalAboveAndPinned += itemNode.apparentBounds.height + } else if index == lowestPinnedIndex { + let pinnedHeight = itemNode.apparentBounds.height + let effectivePinnedHeight = halfArea > 0.0 ? min(pinnedHeight, halfArea) : pinnedHeight + totalAboveAndPinned += effectivePinnedHeight + } + } + guard sawIndexZero else { return 0.0 } + + return max(0.0, visibleArea - totalAboveAndPinned) + } + + private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat { + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + let halfArea = visibleArea * 0.5 + guard halfArea > 0.0 else { return 0.0 } + return max(0.0, pinnedHeight - halfArea) + } +``` + +Key changes: +- `let visibleArea` and `let halfArea` moved up so they're available inside the loop. +- The `if index <= lowestPinnedIndex` branch is split into `< lowestPinnedIndex` (full height) and `== lowestPinnedIndex` (capped height). +- New helper `pinToEdgeBottomExtension(forPinnedHeight:)` added immediately after. + +- [ ] **Step 2: Update `isStrictlyScrolledToPinToEdgeItem()` to use the extension** + +Find (currently around line 2683): + +```swift + for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } + } +``` + +Replace with: + +```swift + for itemNode in self.itemNodes { + if itemNode.index == targetIndex { + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom + return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 + } + } +``` + +- [ ] **Step 3: Update the pin-to-edge-target scroll offset in `replayOperations`** + +Find (currently around line 3127, inside `if isPinToEdgeTarget {`): + +```swift + var offset: CGFloat + if isPinToEdgeTarget { + offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom + } else { +``` + +Replace with: + +```swift + var offset: CGFloat + if isPinToEdgeTarget { + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom + } else { +``` + +- [ ] **Step 4: Sanity-grep for any other use of the old anchor formula** + +Run from repo root: + +```bash +grep -nE "visibleSize\.height\s*-\s*insets?\.bottom\s*\)" submodules/Display/Source/ListView.swift +``` + +Inspect each hit. Sites listed in this plan (now patched) and `else` branches (`.bottom(additionalOffset)`, `.top(additionalOffset)`, `.center`, `.visible` at lines 3131, 3143, 3146, 3154, 3160) are scroll-position offsets for *non-pinned* items — they must NOT change. Confirm the only patched lines are the pin-to-edge-target paths. + +- [ ] **Step 5: Run the full Bazel build with `--continueOnError`** + +```bash +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent \ + --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build completes successfully (no Swift errors). If the build surfaces errors in `submodules/Display/Source/ListView.swift`, re-read the patched sections against Step 1–3 above and fix. + +If unrelated errors appear in untouched files, they are pre-existing — note them and continue. + +- [ ] **Step 6: Commit** + +```bash +git add submodules/Display/Source/ListView.swift +git commit -m "$(cat <<'EOF' +ListView: cap pin-to-edge item visible portion at half area + +When a pinToEdgeWithInset item is taller than half of the visible +area, its visible portion is now capped at halfArea. The remaining +height extends below visibleSize - insets.bottom into the +bottom-inset region, where it is occluded by overlay UI (input +panel, tab bar) in typical usage. + +Three sites updated to a single helper pinToEdgeBottomExtension: +calculatePinToEdgeTopInset (caps the pinned item's contribution), +the pin-to-edge-target scroll offset in replayOperations, and +isStrictlyScrolledToPinToEdgeItem. + +When the pinned item fits within halfArea, extension == 0 and all +three sites compute exactly what they did before. + +Spec: docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md +EOF +)" +``` + +--- + +### Task 2: Manual smoke test + +The change must be exercised on a real device/simulator because there are no unit tests for ListView in this project. + +**Test bench:** any chat that supports `pinToTop` messages (the only consumer of `pinToEdgeWithInset == true` per `ChatMessageItemImpl.swift:280`). + +- [ ] **Step 1: Short pinned message — unchanged behavior** + +Open a chat with a `pinToTop` message of ~1–3 lines (well under `halfArea`). Confirm: +- Pinned message is anchored at the top of the visible chat area (top of screen, given rotated chat). +- The list scrolls under it as expected. +- No visible difference from the pre-change build. + +- [ ] **Step 2: Mid-height pinned message — unchanged behavior** + +Open a chat with a `pinToTop` message that is sized just under `halfArea` (roughly 30–40% of the chat area). Confirm same as Step 1 — no behavior change. + +- [ ] **Step 3: Tall pinned message — capped behavior** + +Open a chat with a `pinToTop` message that is much taller than `halfArea` (e.g., a long text message that would naturally span more than half the visible chat area). Confirm: +- The pinned message occupies at most ~half of the visible chat area at the top of the screen. +- The remaining (lower) half of the chat area shows the regular thread content. +- The portion of the pinned message that "doesn't fit" is not visible — it should be occluded by the input panel / nav bar overlay. +- Scrolling behaves consistently: the pinned message stays anchored at the top edge, the rest of the thread scrolls underneath. + +- [ ] **Step 4: Inset / size changes — re-evaluation** + +While viewing a chat with a tall pinned message, open and close the keyboard (this changes `insets.bottom`). The cap should re-evaluate on each layout pass — no jumpy or stuck state. The pinned message's visible portion should remain ~half of the new visible area. + +If any of Steps 1–4 fails, do NOT call the work done. Re-open the spec, re-read the patched sites in `ListView.swift`, and identify which assumption broke. + +--- + +## Self-review + +**Spec coverage:** +- Helper `pinToEdgeBottomExtension(forPinnedHeight:)` → Task 1 Step 1. +- Cap in `calculatePinToEdgeTopInset` → Task 1 Step 1. +- Anchor change in pin-to-edge-target offset → Task 1 Step 3. +- Anchor change in `isStrictlyScrolledToPinToEdgeItem` → Task 1 Step 2. +- Edge cases (`pinnedHeight ≤ halfArea`, multiple pinned items, degenerate inset, dynamic resize, rotated chats) — covered by the helper's `max(0, …)` and the unchanged `lowestPinnedIndex` selection logic; smoke-tested in Task 2 Steps 1, 2, 4. +- Verification (full build, manual smoke) → Task 1 Step 5, Task 2. +- Risks (overflow into bottom-inset region) → Task 2 Step 3 explicitly checks occlusion. + +**Type/name consistency:** Helper signature `pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat` is identical at the declaration (Step 1) and both call sites (Steps 2, 3). Local variable name `extensionOffset` matches in Steps 2 and 3. + +**Placeholder scan:** No TBDs, no "implement appropriately", no missing code blocks. Each step's expected commands and outcomes are concrete. From ce5e3f4911889f989dd7525f778161b1b16eaed9 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:13:30 +0200 Subject: [PATCH 59/69] ReferenceImpl: reactive audio-SSRC discovery via per-receiver frame transformer Includes spec and plan. --- .../2026-05-01-groupref-ssrc-discovery.md | 1121 +++++++++++++++++ ...26-05-01-groupref-ssrc-discovery-design.md | 354 ++++++ submodules/TgVoipWebrtc/CLAUDE.md | 22 +- submodules/TgVoipWebrtc/tgcalls | 2 +- 4 files changed, 1490 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md create mode 100644 docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md diff --git a/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md b/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md new file mode 100644 index 0000000000..39fb15075b --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md @@ -0,0 +1,1121 @@ +# GroupInstanceReferenceImpl Reactive SSRC Discovery — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `GroupInstanceReferenceImpl`'s test-SFU-only `ActiveAudioSsrcs` discovery path with a per-receiver `webrtc::FrameTransformerInterface` that observes incoming SSRCs at the depacketizer-to-decoder boundary, buffers their first ≤1 s of frames, drives an audio recvonly transceiver to be added via the existing `_requestMediaChannelDescriptions` callback, and flushes the buffer once renegotiation completes — restoring audio in real Telegram group calls without a startup gap. + +**Architecture:** A single `GRAudioFrameTransformer` instance is installed on every audio receiver in this `GroupInstanceReferenceInternal` (mid=0 sendrecv outgoing, plus each recvonly transceiver as it's added). It maintains a per-SSRC state machine (`kBuffering` → `kDrained`) and exposes `releaseSsrc(ssrc)` for the discovery flow to call after `onRenegotiationComplete`. A 250 ms debounce coalesces bursts into one renegotiation. The `ActiveAudioSsrcs` data-channel mechanism (test-SFU only) is removed from both the Go SFU and the C++ ReferenceImpl so the tap is the single discovery path in test and production. + +**Tech Stack:** C++17, WebRTC `FrameTransformerInterface` API, Bazel 8.4.2, the existing tgcalls test bench (Go/Pion SFU + `tgcalls_cli` integration tests). + +--- + +## Reference: Spec & key call-sites + +- **Spec:** `docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md` +- **WebRTC plumbing reference:** `third-party/webrtc/webrtc/api/frame_transformer_interface.h` (the `FrameTransformerInterface` / `TransformableFrameInterface` / `TransformedFrameCallback` contract); `third-party/webrtc/webrtc/media/engine/webrtc_voice_engine.cc:2240-2266` (the unsignaled→signaled stream promotion path that lets buffered frames flow into the same receive stream). +- **C++ files we modify:** `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` (only `.cpp`; the `.h` doesn't change). +- **Go file we modify:** `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go`. +- **Docs to refresh:** `submodules/TgVoipWebrtc/CLAUDE.md` (ReferenceImpl section), `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` (group-mode SSRC discovery flow), `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` (drop the `ActiveAudioSsrcs` mention). + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` | modify | Add `GRAudioFrameTransformer` (anonymous-namespace), wire into `start()` and `renegotiate()`, add `handleDiscoveredAudioSsrc` / `scheduleDiscoveryRenegotiation`, delete `handleActiveAudioSsrcs` and its dispatch, call `releaseSsrc` in `onRenegotiationComplete`, add `_audioFrameTransformer` and `_discoveryRenegotiationScheduled` members. | +| `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` | modify | Delete `buildActiveSSRCsMessage`, `broadcastActiveSSRCs`, both call-sites (post-Join goroutine line 330, Leave line 1061). The `participantSSRCs` audio-collection helper used by `broadcastActiveSSRCs` is otherwise unused; remove it. | +| `submodules/TgVoipWebrtc/CLAUDE.md` | modify | Update the "GroupInstanceReferenceImpl" section: SSRC discovery now via tap; remove "ActiveAudioSsrcs" reference; add note that `_audioFrameTransformer` is the seam where e2e decrypt will land. | +| `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` | modify | Remove the `ActiveAudioSsrcs` line from the group-mode flow description. | +| `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` | modify | Remove "ActiveAudioSsrcs" from the `sfu.go` bullet. | + +No new files. No iOS code touched. No CLI test-tool code touched (we ride on the existing `--mute-participants` and per-SSRC level invariants from the previous fix). + +--- + +## Test bench reference + +The existing CLI test (built earlier in this branch) is the integration harness. After every code change, the regression sweep is: + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 + +# T1: All-Reference, no mute — must SUCCESS +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +# Expected: "Result: SUCCESS", exit code 0 + +# T2: Mixed (2C+2R) — must SUCCESS +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 2 --reference-participants 2 --duration 6 --quiet + +# T3: All-Reference with P0 muted — must SUCCESS (muted-peer invariant) +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 --quiet + +# T4: Mixed muted (custom side muted) — must SUCCESS +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 1 --reference-participants 2 --mute-participants 0 --duration 6 --quiet + +# T5: Mixed muted (reference side muted) — must SUCCESS +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 2 --reference-participants 1 --mute-participants 2 --duration 6 --quiet +``` + +Tasks below name a specific subset of T1–T5 to run as the verification step. Always run **all five** before the final commit of each task — the muted-peer invariant from the audio-level fix is the strongest detector for routing regressions. + +--- + +### Task 1: Delete `ActiveAudioSsrcs` from the test SFU + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` + +**Why first:** Forces every subsequent test run to exercise the discovery path we're about to build. Until the tap is in, all-Reference tests will fail (expected); mixed tests will still pass (CustomImpl discovers via raw RTP). This intentional regression is the visible TDD-red for the rest of the plan. + +- [ ] **Step 1: Read the current state** + +Confirm the three locations with: + +```bash +grep -n "broadcastActiveSSRCs\|buildActiveSSRCsMessage" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go +``` + +Expected output: +``` +330: s.broadcastActiveSSRCs() +886:// broadcastActiveSSRCs sends the current set of active audio SSRCs to all connected participants. +888:func (s *SFU) broadcastActiveSSRCs() { +911: msg := buildActiveSSRCsMessage(ssrcs) +986:func buildActiveSSRCsMessage(ssrcs []int32) string { +1061: s.broadcastActiveSSRCs() +``` + +- [ ] **Step 2: Delete the two call-sites** + +Remove the line `s.broadcastActiveSSRCs()` at line 330 (inside the post-Join goroutine, immediately above `s.broadcastActiveVideoSSRCs()`). Remove the line `s.broadcastActiveSSRCs()` at line 1061 (inside `Leave`, immediately above `s.broadcastActiveVideoSSRCs()`). + +- [ ] **Step 3: Delete `broadcastActiveSSRCs` and `buildActiveSSRCsMessage`** + +Delete the entire function `broadcastActiveSSRCs` (the doc-comment at line 886 through the closing `}` of the function around line 916, including the `participantSSRCs` map it builds locally). + +Delete the entire function `buildActiveSSRCsMessage` (line 986 through line 996). + +- [ ] **Step 4: Verify nothing else references them** + +```bash +grep -n "broadcastActiveSSRCs\|buildActiveSSRCsMessage\|ActiveAudioSsrcs" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go +``` + +Expected output: empty. + +- [ ] **Step 5: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 6: Run T1 (all-Reference) — confirm RED** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`, `Audio received: 0/3`, `EXIT=1`. (No discovery → no recvonly transceivers → no audio.) + +- [ ] **Step 7: Run T2 (mixed) — confirm partial regression** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 2 --reference-participants 2 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`. CustomImpl peers will still discover ReferenceImpl audio via raw RTP (so they receive ReferenceImpl audio), but ReferenceImpl peers cannot discover anyone (so they receive nothing). `Audio received` will report something less than 4/4. + +- [ ] **Step 8: Commit the intentional regression** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go +git commit -m "$(cat <<'EOF' +test sfu: remove ActiveAudioSsrcs broadcast + +The test-only ActiveAudioSsrcs colibri message is being replaced by +ReferenceImpl's per-receiver FrameTransformer-based discovery (next +commits). Removing the message first forces every subsequent test +run to exercise the new code path — keeping the message in place +would let test passes mask production-real bugs. + +T1/T2 currently FAIL as expected; T3-T5 likewise — the muted-peer +invariant cannot hold without working discovery. All restored by the +end of this PR. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2: Delete `handleActiveAudioSsrcs` from `GroupInstanceReferenceImpl` + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` + +**Why now:** The handler is now dead code (no SFU sends `ActiveAudioSsrcs` anymore). Removing it before adding the new path means we don't temporarily have two SSRC-add paths that could both insert the same entry into `_remoteSsrcs`. + +- [ ] **Step 1: Confirm the call sites** + +```bash +grep -n "handleActiveAudioSsrcs\|colibriClass.*ActiveAudio" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +``` + +Expected: +``` +1060: if (colibriClass == "ActiveAudioSsrcs") { +1061: handleActiveAudioSsrcs(json); +1063: void handleActiveAudioSsrcs(json11::Json const &json) { +``` + +- [ ] **Step 2: Read the dispatch site** + +Read lines 1048–1067 of `GroupInstanceReferenceImpl.cpp` to confirm the surrounding context of `onDataChannelMessage`. + +- [ ] **Step 3: Remove the dispatch (lines 1056–1067)** + +Inside `onDataChannelMessage`, locate the `colibriClass == "ActiveAudioSsrcs"` branch and the comment immediately above (`// ActiveVideoSsrcs and SenderVideoConstraints are handled by the` ... `// setRequestedVideoChannels() in response.`). Delete the JSON parse block, the colibriClass extraction, and the if-branch that calls `handleActiveAudioSsrcs(json)`. + +The remaining body of `onDataChannelMessage` should consist of the `_dataChannelMessageReceived` forwarding only: + +```cpp + void onDataChannelMessage(std::string const &msg) { + // Forward all data channel messages to the application. + // Audio SSRCs are now discovered by the per-receiver frame + // transformer (see GRAudioFrameTransformer); video channel + // requests are app-driven via setRequestedVideoChannels. + if (_dataChannelMessageReceived) { + _dataChannelMessageReceived(msg); + } + } +``` + +- [ ] **Step 4: Delete `handleActiveAudioSsrcs`** + +Delete the entire function `handleActiveAudioSsrcs(json11::Json const &json)` (starts at the line that previously matched `1063: void handleActiveAudioSsrcs(json11::Json const &json) {`, ends at the matching `}`). It's roughly 60 lines including the diff loop and the `_requestMediaChannelDescriptions` call. + +- [ ] **Step 5: Verify nothing else references it** + +```bash +grep -n "handleActiveAudioSsrcs\|ActiveAudioSsrcs" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +``` + +Expected: empty. + +- [ ] **Step 6: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 7: Run T1 — still RED, same reason** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`, `EXIT=1`. (No regression added beyond Task 1; we just deleted dead code.) + +- [ ] **Step 8: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: remove handleActiveAudioSsrcs + +ActiveAudioSsrcs was a test-SFU-only colibri message. With the test +SFU no longer sending it, the handler is dead code. Removing now +before introducing the new discovery path so we don't briefly have +two paths inserting into _remoteSsrcs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3: Add `GRAudioFrameTransformer` skeleton (no behavior yet) + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` + +**Why staged:** Adding the class first as a do-nothing pass-through transformer lets us verify the WebRTC plumbing works (the catch-all transformer never breaks audio, OnTransformedFrame is reachable) before we layer on the per-SSRC state machine. Initially we also install nothing — the class just compiles. + +- [ ] **Step 1: Locate the anonymous namespace insertion point** + +Read lines 109–148 of `GroupInstanceReferenceImpl.cpp` to confirm where the anonymous namespace ends (the `} // anonymous namespace` line). + +- [ ] **Step 2: Add the class right before `} // anonymous namespace`** + +Insert the following code immediately before the closing `} // anonymous namespace` (after `GRCreateSDPObserver`): + +```cpp +// --- Per-receiver audio frame transformer --- +// +// One instance is installed (via `RtpReceiverInterface::SetDepacketizerToDecoderFrameTransformer`) +// on every audio receiver in this GroupInstanceReferenceInternal: +// - mid=0 sendrecv outgoing audio (its receive side acts as the catch-all +// for unsignaled SSRCs); +// - each recvonly audio transceiver added by the SSRC-discovery flow. +// +// Behavior per SSRC: +// - First-sight (no entry yet): notify discovery; buffer the frame. +// - kBuffering (waiting for renegotiation): append to per-SSRC FIFO. +// - kDrained (releaseSsrc has fired): pass through immediately. +// +// `releaseSsrc(ssrc)` is invoked by ReferenceImpl on the media thread +// from `onRenegotiationComplete` once a recvonly transceiver owns the +// SSRC. The transformer drains the per-SSRC FIFO via OnTransformedFrame +// and transitions the entry to kDrained. +// +// `decryptHook` is reserved for the e2e fix (separate PR). Today it is +// nullptr and the transformer just forwards frames unchanged. +class GRAudioFrameTransformer : public webrtc::FrameTransformerInterface { +public: + using SsrcCallback = std::function; + using DecryptHook = std::function; + + GRAudioFrameTransformer(SsrcCallback onNewSsrc, + DecryptHook decrypt, + rtc::Thread* mediaThread) + : _onNewSsrc(std::move(onNewSsrc)), + _decrypt(std::move(decrypt)), + _mediaThread(mediaThread) {} + + // Stub — to be filled in Task 4. + void Transform(std::unique_ptr frame) override { + // Pass-through for now (verifies plumbing). + webrtc::MutexLock lock(&_mu); + const uint32_t ssrc = frame->GetSsrc(); + rtc::scoped_refptr sink; + auto it = _perSsrcSinks.find(ssrc); + if (it != _perSsrcSinks.end()) { + sink = it->second; + } else if (_broadcastSink) { + sink = _broadcastSink; + } + if (!sink) return; + sink->OnTransformedFrame(std::move(frame)); + } + + // Stub — to be filled in Task 4. + void releaseSsrc(uint32_t /*ssrc*/) {} + + void RegisterTransformedFrameCallback( + rtc::scoped_refptr cb) override { + webrtc::MutexLock lock(&_mu); + _broadcastSink = std::move(cb); + } + void RegisterTransformedFrameSinkCallback( + rtc::scoped_refptr cb, + uint32_t ssrc) override { + webrtc::MutexLock lock(&_mu); + _perSsrcSinks[ssrc] = std::move(cb); + } + void UnregisterTransformedFrameCallback() override { + webrtc::MutexLock lock(&_mu); + _broadcastSink = nullptr; + } + void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) override { + webrtc::MutexLock lock(&_mu); + _perSsrcSinks.erase(ssrc); + } + +private: + SsrcCallback _onNewSsrc; + DecryptHook _decrypt; + rtc::Thread* _mediaThread; // identity only; not used for thread-affine asserts in this skeleton + + webrtc::Mutex _mu; + rtc::scoped_refptr _broadcastSink RTC_GUARDED_BY(_mu); + std::map> _perSsrcSinks RTC_GUARDED_BY(_mu); +}; +``` + +- [ ] **Step 3: Add a `webrtc::Mutex` include if missing** + +```bash +grep -n "rtc_base/synchronization/mutex.h\|webrtc::Mutex\b" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp | head -5 +``` + +If the include is missing, add to the includes near the top of the file (after the existing webrtc includes): + +```cpp +#include "rtc_base/synchronization/mutex.h" +``` + +- [ ] **Step 4: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 +``` + +Expected: `INFO: Build completed successfully`. + +If the build fails on `webrtc::Mutex` — try `#include "api/sequence_checker.h"` and use `webrtc::Mutex` from there, or fall back to `std::mutex` (the rest of the file already uses `` via `_audioLevelsMutex` in `tools/cli/group_participant.cpp`; check what's available in `tgcalls/tgcalls/group/`). + +- [ ] **Step 5: Run T1 — still expected to fail (we haven't installed the transformer yet)** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`, `EXIT=1`. The class is defined but unused — same red as Tasks 1–2. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: add GRAudioFrameTransformer skeleton + +Pass-through frame transformer scaffolding. Behavior (per-SSRC +buffering, releaseSsrc, decrypt hook) lands in the next commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 4: Implement the per-SSRC state machine in `GRAudioFrameTransformer` + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` (the class added in Task 3) + +**Why staged:** Once installed (Task 5), this class becomes part of every audio frame's path. Splitting "skeleton" from "behavior" makes the diff small enough to review carefully — bugs here silently drop audio. + +- [ ] **Step 1: Add the `Entry` struct, constants, and `_entries` map to the class** + +Inside `class GRAudioFrameTransformer` (after `_perSsrcSinks` declaration), add: + +```cpp +private: + enum class SsrcState { kBuffering, kDrained }; + + struct Entry { + SsrcState state = SsrcState::kBuffering; + std::deque> buffer; + int64_t firstFrameTimeMs = 0; + }; + + static constexpr int64_t kSsrcDiscoveryTimeoutMs = 1000; + static constexpr size_t kMaxBufferedFramesPerSsrc = 60; + static constexpr size_t kMaxConcurrentBufferedSsrcs = 64; + + void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu); + + std::map _entries RTC_GUARDED_BY(_mu); +``` + +Add `` to the includes if not already present (usually pulled in transitively): + +```bash +grep -n "" /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +``` + +If missing, add `#include ` near the top with the other STL includes. + +- [ ] **Step 2: Replace `Transform` with the real implementation** + +Replace the stub `Transform` body with: + +```cpp + void Transform(std::unique_ptr frame) override { + if (!frame) return; + const uint32_t ssrc = frame->GetSsrc(); + + // Path A: kDrained — pass through. + // Path B: kBuffering — buffer. + // Path C: new SSRC — insert + post discovery + buffer. + // Path D: at the SSRC cap — drop silently. + rtc::scoped_refptr liveSink; + bool notifyDiscovery = false; + std::unique_ptr liveFrame; + { + webrtc::MutexLock lock(&_mu); + evictExpired_n(); + + auto it = _entries.find(ssrc); + if (it == _entries.end()) { + if (_entries.size() >= kMaxConcurrentBufferedSsrcs) { + // Path D: drop without notify, no allocation. + return; + } + Entry entry; + entry.firstFrameTimeMs = rtc::TimeMillis(); + entry.buffer.push_back(std::move(frame)); + _entries.emplace(ssrc, std::move(entry)); + notifyDiscovery = true; + } else if (it->second.state == SsrcState::kBuffering) { + if (it->second.buffer.size() >= kMaxBufferedFramesPerSsrc) { + it->second.buffer.pop_front(); + } + it->second.buffer.push_back(std::move(frame)); + } else { + // kDrained: pick the live sink and emit outside the lock. + auto sinkIt = _perSsrcSinks.find(ssrc); + if (sinkIt != _perSsrcSinks.end()) { + liveSink = sinkIt->second; + } else { + liveSink = _broadcastSink; + } + liveFrame = std::move(frame); + } + } + + if (liveSink && liveFrame) { + liveSink->OnTransformedFrame(std::move(liveFrame)); + } + if (notifyDiscovery && _onNewSsrc) { + _onNewSsrc(ssrc); + } + } +``` + +- [ ] **Step 3: Replace `releaseSsrc` with the real implementation** + +Replace the stub `releaseSsrc` body with: + +```cpp + void releaseSsrc(uint32_t ssrc) { + std::deque> toFlush; + rtc::scoped_refptr sink; + { + webrtc::MutexLock lock(&_mu); + auto it = _entries.find(ssrc); + if (it == _entries.end() || it->second.state == SsrcState::kDrained) { + // Either the SSRC was unknown, or already drained. Mark drained + // so future frames take the live-passthrough path. + if (it == _entries.end()) { + Entry entry; + entry.state = SsrcState::kDrained; + entry.firstFrameTimeMs = rtc::TimeMillis(); + _entries.emplace(ssrc, std::move(entry)); + } + return; + } + it->second.state = SsrcState::kDrained; + toFlush = std::move(it->second.buffer); + + auto sinkIt = _perSsrcSinks.find(ssrc); + if (sinkIt != _perSsrcSinks.end()) { + sink = sinkIt->second; + } else { + sink = _broadcastSink; + } + } + + if (!sink) return; + while (!toFlush.empty()) { + auto f = std::move(toFlush.front()); + toFlush.pop_front(); + sink->OnTransformedFrame(std::move(f)); + } + } +``` + +- [ ] **Step 4: Implement `evictExpired_n`** + +Add this private method definition inside the class (e.g., after `releaseSsrc`): + +```cpp + void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu) { + const int64_t now = rtc::TimeMillis(); + for (auto& [ssrc, entry] : _entries) { + if (entry.state == SsrcState::kBuffering && + !entry.buffer.empty() && + (now - entry.firstFrameTimeMs) > kSsrcDiscoveryTimeoutMs) { + entry.buffer.clear(); + // Leave the entry in kBuffering with empty buffer so we don't + // re-notify discovery. If the application ever recovers + // (renegotiation completes belatedly), releaseSsrc will still + // mark kDrained and live frames will flow through. + } + } + } +``` + +- [ ] **Step 5: Add `rtc::TimeMillis` include if missing** + +```bash +grep -n "rtc::TimeMillis\b\|rtc_base/time_utils.h" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp | head -5 +``` + +If only the call shows up but not the header, add: + +```cpp +#include "rtc_base/time_utils.h" +``` + +near the other rtc_base includes. + +- [ ] **Step 6: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 7: Run T1 — still expected to fail (transformer still not installed)** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`, `EXIT=1`. Code is built but unused. + +- [ ] **Step 8: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: implement GRAudioFrameTransformer state machine + +Per-SSRC buffer / drain / passthrough. releaseSsrc transitions +kBuffering→kDrained atomically (mark under lock, drain outside lock) +to avoid races. evictExpired_n bounds buffer lifetime to 1s. + +Caps: kMaxConcurrentBufferedSsrcs=64, kMaxBufferedFramesPerSsrc=60. +Worst-case memory ≈ 308 KB. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5: Wire the transformer into `start()` (catch-all only) and observe discovery callback fires + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` + +**Why staged:** Install on the catch-all and prove `Transform` fires (via the `_onNewSsrc` callback). Discovery doesn't yet drive a renegotiation — that's the next task. After this task, T1 should still FAIL but with the discovery callback observably firing. + +- [ ] **Step 1: Add the member field** + +Locate the `Audio.` block in the private member section (around line 1556 — `_outgoingAudioTrack`, `_outgoingAudioTransceiver`). Add immediately after: + +```cpp + // Per-receiver audio frame transformer (catch-all + every recvonly). + rtc::scoped_refptr _audioFrameTransformer; +``` + +- [ ] **Step 2: Add a forward declaration for `handleDiscoveredAudioSsrc` (function defined in Task 6)** + +Inside the class, near the other private method declarations / definitions for `handleActiveAudioSsrcs`-replacement work — for now, declare a stub: + +```cpp + void handleDiscoveredAudioSsrc(uint32_t ssrc) { + // To be implemented in Task 6. + RTC_LOG(LS_INFO) << "GroupRef: discovered audio SSRC " << ssrc; + } +``` + +Place this near the existing media-thread methods (e.g., near `wireRemoteAudioLevelSinks`). + +- [ ] **Step 3: Construct + install the transformer in `start()`** + +Locate the block after `_outgoingAudioTransceiver` is created (around line 386, immediately after the `params.encodings[0].max_bitrate_bps = ...; _outgoingAudioTransceiver->sender()->SetParameters(params);` block). Insert before `_outgoingAudioTrack->set_enabled(false); // Muted by default.`: + +```cpp + // Install the audio frame transformer on mid=0's receiver. The + // receive side of this sendrecv transceiver is PeerConnection's + // catch-all for unsignaled SSRCs, so the transformer captures + // every previously-unseen remote SSRC. The same instance is + // attached to each recvonly receiver in renegotiate() so that + // live audio passes through the transformer consistently + // (and so the e2e PR has a single attachment point). + _audioFrameTransformer = rtc::make_ref_counted( + /*onNewSsrc=*/[weak, threads = _threads](uint32_t ssrc) { + threads->getMediaThread()->PostTask([weak, ssrc]() { + if (auto strong = weak.lock()) { + strong->handleDiscoveredAudioSsrc(ssrc); + } + }); + }, + /*decrypt=*/nullptr, // wired by the e2e PR + /*mediaThread=*/_threads->getMediaThread().get()); + _outgoingAudioTransceiver->receiver() + ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); +``` + +Note: `weak` is the `weak_ptr` already in scope earlier in `start()`. Verify by re-reading the start of `start()`: + +```bash +sed -n '173,195p' /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +``` + +If `weak` is not in scope at the insertion point, declare locally: + +```cpp + const auto weak = std::weak_ptr(shared_from_this()); +``` + +- [ ] **Step 4: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 5: Run T1 with verbose output — confirm discovery callback fires** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 2>&1 \ + | grep "discovered audio SSRC" | head -10 +``` + +Expected: at least one `GroupRef: discovered audio SSRC ` line per remote peer per participant. (Logs go through `LogSinkImpl` which writes to a per-participant log file then deletes; if the grep returns nothing, briefly add `fprintf(stderr, "...")` mirroring the RTC_LOG to verify, then revert before commit.) + +- [ ] **Step 6: Run T1 (full) — still expected to FAIL** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: FAILED`. The handler is a logging stub; no recvonly transceiver is added; audio is dropped (no `OnTransformedFrame` call yet because the entry is `kBuffering` indefinitely). + +- [ ] **Step 7: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: install GRAudioFrameTransformer on mid=0 + +The transformer is now in the depacketizer path of mid=0's receiver +(PeerConnection's catch-all for unsignaled audio). Every previously- +unseen SSRC fires the discovery callback, which currently just logs. +The next commit drives renegotiation and flushes buffered audio. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6: Implement `handleDiscoveredAudioSsrc` + `scheduleDiscoveryRenegotiation` and call `releaseSsrc` from `onRenegotiationComplete` + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` + +**Why staged:** This is the GREEN step — the test harness should pass for the first time after this task. Wiring discovery → renegotiation → release closes the loop, but until we also install the transformer on each recvonly receiver (Task 7), live frames after the flush continue to take the catch-all path. That's tolerable today (catch-all is the same instance) but Task 7 makes it explicit and future-proof. + +- [ ] **Step 1: Add `_discoveryRenegotiationScheduled` member** + +In the private member section, near `_isRenegotiating`: + +```cpp + // Discovery-renegotiation debounce. + bool _discoveryRenegotiationScheduled = false; +``` + +- [ ] **Step 2: Replace the `handleDiscoveredAudioSsrc` stub with the real implementation** + +```cpp + // Single entry point for adding a remote audio SSRC. Runs on the + // media thread (posted to from the worker-thread frame transformer + // callback). + void handleDiscoveredAudioSsrc(uint32_t ssrc) { + if (ssrc == 0) return; + if (ssrc == _outgoingSsrc) return; + if (_remoteSsrcs.count(ssrc) > 0) return; + + std::string mid = std::to_string(_nextMid++); + RemoteSsrcInfo info; + info.mid = mid; + _remoteSsrcs.emplace(ssrc, std::move(info)); + + if (_requestMediaChannelDescriptions) { + _requestMediaChannelDescriptions({ssrc}, + [](std::vector&&) {}); + } + scheduleDiscoveryRenegotiation(); + + RTC_LOG(LS_INFO) << "GroupRef: queued discovered audio SSRC " << ssrc + << " (mid=" << mid << ")"; + } +``` + +- [ ] **Step 3: Add `scheduleDiscoveryRenegotiation`** + +Place near the existing `renegotiate()` method: + +```cpp + static constexpr int kDiscoveryRenegotiationDelayMs = 250; + + void scheduleDiscoveryRenegotiation() { + if (_discoveryRenegotiationScheduled) return; + _discoveryRenegotiationScheduled = true; + + const auto weak = std::weak_ptr(shared_from_this()); + _threads->getMediaThread()->PostDelayedTask( + [weak]() { + auto strong = weak.lock(); + if (!strong) return; + strong->_discoveryRenegotiationScheduled = false; + strong->renegotiate(); + }, + webrtc::TimeDelta::Millis(kDiscoveryRenegotiationDelayMs)); + } +``` + +- [ ] **Step 4: Add the `releaseSsrc` loop in `onRenegotiationComplete`** + +Locate `onRenegotiationComplete()` (around line 1314). Insert after `wireRemoteAudioLevelSinks();` and before `_isRenegotiating = false;`: + +```cpp + if (_audioFrameTransformer) { + for (auto& [ssrc, info] : _remoteSsrcs) { + if (info.transceiver && info.transceiver->mid().has_value()) { + _audioFrameTransformer->releaseSsrc(ssrc); + } + } + } +``` + +- [ ] **Step 5: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 6: Run T1 — should now SUCCESS** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet +echo "EXIT=$?" +``` + +Expected: `Result: SUCCESS`, `Audio received: 3/3`, `EXIT=0`. + +If FAIL: re-read the spec's "Where flushed frames actually go" section. The most likely issue is that the per-SSRC sink callback registered when the unsignaled stream was created is no longer the one routing to the (now-promoted) audio receive stream's decoder. Add a one-shot debug print inside `releaseSsrc` to confirm `sink != nullptr` and `toFlush.size() > 0`. + +- [ ] **Step 7: Run T2-T5 — verify no regressions** + +Run each scenario from the "Test bench reference" block above (T1 through T5). All five must report `Result: SUCCESS` and `EXIT=0`. + +If T3-T5 (muted-peer scenarios) pass: the muted-peer invariant from the previous fix still holds, so the per-receiver audio-level sinks are correctly wired and reading PCM from the post-promotion stream. + +- [ ] **Step 8: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: drive discovery renegotiation, flush buffer + +- handleDiscoveredAudioSsrc inserts into _remoteSsrcs, fires + _requestMediaChannelDescriptions per SSRC, schedules a debounced + renegotiation. +- scheduleDiscoveryRenegotiation coalesces bursts: at most one + renegotiation per 250ms, regardless of how many SSRCs land in + the window. +- onRenegotiationComplete iterates _remoteSsrcs and calls + releaseSsrc(ssrc) for every entry whose transceiver now has a + mid, draining the per-SSRC FIFO into OnTransformedFrame so the + buffered audio plays without a startup gap. + +T1-T5 all pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7: Install `_audioFrameTransformer` on every recvonly transceiver as it's added + +**Files:** +- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` + +**Why now (not in Task 6):** Today the catch-all transformer covers live audio for promoted streams (it's already on the stream from the unsignaled-stream creation path). Explicitly installing it on each recvonly receiver removes our reliance on internal WebRTC stream-promotion behavior carrying the transformer along, and matches the exact attachment pattern the e2e PR will use. + +- [ ] **Step 1: Find the AddTransceiver call in `renegotiate()`** + +```bash +grep -n "AddTransceiver(cricket::MEDIA_TYPE_AUDIO" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +``` + +Expected: a single hit inside `renegotiate()` (around line 1142). + +- [ ] **Step 2: Attach the transformer right after the transceiver is created** + +Modify the block that handles the success branch of `AddTransceiver`. Original: + +```cpp + auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); + if (result.ok()) { + info.transceiver = result.value(); + RTC_LOG(LS_INFO) << "GroupRef: Added recvonly transceiver for SSRC " << ssrc; + } +``` + +Replace with: + +```cpp + auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); + if (result.ok()) { + info.transceiver = result.value(); + if (_audioFrameTransformer) { + info.transceiver->receiver() + ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); + } + RTC_LOG(LS_INFO) << "GroupRef: Added recvonly transceiver for SSRC " << ssrc; + } +``` + +- [ ] **Step 3: Build** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 +``` + +Expected: `INFO: Build completed successfully`. + +- [ ] **Step 4: Run T1-T5 — must all SUCCESS** + +```bash +for cfg in \ + "0 3 --duration 6" \ + "2 2 --duration 6" \ + "0 3 --mute-participants 0 --duration 6" \ + "1 2 --mute-participants 0 --duration 6" \ + "2 1 --mute-participants 2 --duration 6"; do + /Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants $cfg --quiet 2>&1 | tail -3 + echo "EXIT=$?"; echo "---" +done +``` + +Each block must end with `Result: SUCCESS` and `EXIT=0`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp +git commit -m "$(cat <<'EOF' +GroupInstanceReferenceImpl: attach frame transformer to each recvonly receiver + +Live audio for a promoted SSRC was already flowing through our +transformer (carried over from the unsignaled stream creation +path), but we depended on internal WebRTC stream-promotion +behavior to make that work. Explicit per-receiver attachment +removes that dependency and matches what the e2e PR will need. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 8: Documentation refresh + +**Files:** +- Modify: `submodules/TgVoipWebrtc/CLAUDE.md` +- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` +- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` + +- [ ] **Step 1: Update `submodules/TgVoipWebrtc/CLAUDE.md` — ReferenceImpl section** + +Find the "GroupInstanceReferenceImpl" section. Update the "Dynamic Participant Handling → Audio" sub-bullet: + +Before (or similar): +> 1. SFU sends `{"colibriClass":"ActiveAudioSsrcs","ssrcs":[54321,98765]}` over data channel +> 2. Client diffs against known SSRCs +> 3. New SSRCs: add recvonly audio transceiver → renegotiate (new offer + constructed answer mirroring offer mids) +> 4. Removed SSRCs: clean up from tracking map + +After: +> 1. `GRAudioFrameTransformer` (installed on mid=0's receiver and on every recvonly audio receiver) sees a frame with an unknown SSRC at the depacketizer→decoder boundary, buffers it, and notifies the media thread. +> 2. `handleDiscoveredAudioSsrc` inserts the SSRC into `_remoteSsrcs`, fires `_requestMediaChannelDescriptions({ssrc}, ...)` (matches CustomImpl's contract), and schedules a 250 ms-debounced renegotiation. +> 3. Renegotiation adds a recvonly transceiver bound to the new SSRC; `buildRemoteAnswer` includes the SSRC on the new m-line; `WebRtcVoiceReceiveChannel::AddRecvStream` promotes the existing unsignaled stream in place. +> 4. `onRenegotiationComplete` calls `_audioFrameTransformer->releaseSsrc(ssrc)`, which drains the buffered FIFO via `OnTransformedFrame` so the user hears the participant's first ~250–500 ms of audio without a gap. +> 5. Subsequent live frames for the SSRC pass through the transformer's `kDrained` branch directly to the decoder. +> +> The `colibriClass=ActiveAudioSsrcs` data-channel mechanism (test-SFU only) was removed; the tap is the single discovery path. Removed-SSRC handling is the same as CustomImpl's: stale recvonly transceivers stay in the SDP indefinitely; participant departures are tracked at the application layer (MTProto). + +- [ ] **Step 2: Update `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — group flow** + +Find the "Architecture (Group)" section. Remove the bullet about `ActiveAudioSsrcs` ("SSRC discovery: SFU broadcasts `ActiveAudioSsrcs` and `ActiveVideoSsrcs` ...") and replace with: + +> SSRC discovery: video SSRCs are broadcast via `ActiveVideoSsrcs` (used by the test app's `dataChannelMessageReceived` callback to call `setRequestedVideoChannels`). Audio SSRCs are discovered by `GroupInstanceReferenceImpl`'s per-receiver `GRAudioFrameTransformer` directly from incoming RTP — same shape CustomImpl uses (`receiveUnknownSsrcPacket` → `_requestMediaChannelDescriptions`). + +- [ ] **Step 3: Update `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md`** + +In the bullet that lists `sfu.go`'s responsibilities, remove the phrase `'ActiveAudioSsrcs'/`ActiveVideoSsrcs' broadcasting` and replace with `ActiveVideoSsrcs broadcasting` (audio is no longer broadcast). + +- [ ] **Step 4: Verify builds still work after the doc changes (defensive)** + +```bash +/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ + build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 +``` + +Expected: `INFO: Build completed successfully` (no source files touched, but a no-op build verifies the docs don't accidentally break a generated file). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git add submodules/TgVoipWebrtc/CLAUDE.md \ + submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md \ + submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md +git commit -m "$(cat <<'EOF' +docs: ReferenceImpl SSRC discovery via per-receiver frame transformer + +Document the new GRAudioFrameTransformer-based audio SSRC discovery +flow in both the tgcalls library overview and the test-bench notes. +Drop ActiveAudioSsrcs references — the message no longer exists. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 9: Final regression sweep + +**Files:** none changed. + +- [ ] **Step 1: Run the full sweep** + +```bash +echo "=== T1: All-Reference no mute ===" +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --duration 6 --quiet | tail -3 +echo "EXIT=$?" + +echo "=== T2: Mixed (2C+2R) no mute ===" +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 2 --reference-participants 2 --duration 6 --quiet | tail -3 +echo "EXIT=$?" + +echo "=== T3: All-Reference, P0 muted ===" +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 --quiet | tail -3 +echo "EXIT=$?" + +echo "=== T4: Mixed (1C+2R), custom muted ===" +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 1 --reference-participants 2 --mute-participants 0 --duration 6 --quiet | tail -3 +echo "EXIT=$?" + +echo "=== T5: Mixed (2C+1R), reference muted ===" +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 2 --reference-participants 1 --mute-participants 2 --duration 6 --quiet | tail -3 +echo "EXIT=$?" +``` + +Expected: every block ends with `Result: SUCCESS` and `EXIT=0`. + +- [ ] **Step 2: Confirm muted-peer level invariant explicitly (T3 verbose)** + +```bash +/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ + --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 2>&1 \ + | grep -E "Validate" | head -5 +``` + +Expected: +``` +Validate: OK: P1 (ref) reported muted P0 (ssrc=...) at max level 0.000 +Validate: OK: P2 (ref) reported muted P0 (ssrc=...) at max level 0.000 +``` + +- [ ] **Step 3: Confirm no leftover `ActiveAudioSsrcs` references** + +```bash +grep -rn "ActiveAudioSsrcs\|handleActiveAudioSsrcs\|broadcastActiveSSRCs\|buildActiveSSRCsMessage" \ + /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/ 2>/dev/null | grep -v ".log\|.o\|index/\|bazel-" +``` + +Expected: empty (or only matches inside `.git/`). + +- [ ] **Step 4: Confirm git log structure** + +```bash +cd /Users/isaac/build/telegram/telegram-ios +git log --oneline -10 +``` + +Expected to see 8 new commits from this PR (Tasks 1, 2, 3, 4, 5, 6, 7, 8) since the previous baseline. + +- [ ] **Step 5: This task has no commit** + +The sweep is verification-only. + +--- + +## Out of scope (do NOT touch in this PR) + +- `descriptor.e2eEncryptDecrypt` wiring. The seam (`DecryptHook` constructor parameter on `GRAudioFrameTransformer`) is in place; the actual decrypt callback is the next PR. +- Outgoing-audio SSRC>int31 masking. Separate fix. +- Push-style discovery via a new `GroupInstanceInterface::addIncomingAudioSsrcs(...)` method. +- Removed-SSRC cleanup (recvonly transceivers stay in the SDP indefinitely — same as CustomImpl). +- iOS-side code changes — the existing `_requestMediaChannelDescriptions` callback already serves the discovery path. + +--- + +## Self-review notes + +- **Spec coverage:** Every section of the spec has at least one task. Frame transformer (Tasks 3–4), every-receiver install (Tasks 5, 7), discovery + debounce + release (Task 6), `ActiveAudioSsrcs` removal (Tasks 1–2), docs (Task 8), regression sweep (Task 9). +- **Type / name consistency:** `GRAudioFrameTransformer` (not `GRSsrcTapTransformer`) used everywhere. Member field `_audioFrameTransformer` everywhere. Constants `kSsrcDiscoveryTimeoutMs`, `kMaxBufferedFramesPerSsrc`, `kMaxConcurrentBufferedSsrcs`, `kDiscoveryRenegotiationDelayMs` defined once and referenced once. +- **Placeholder scan:** No "TBD"/"TODO"/"similar to N" — every code-touching step has the actual code. +- **Test verification:** The test bench T1–T5 are referenced by exact CLI invocations, with the muted-peer invariant called out as the strongest signal for routing regressions. Task 1's intentional regression is explicitly framed as TDD-red so the engineer doesn't think they broke something. diff --git a/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md b/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md new file mode 100644 index 0000000000..1ee706a42a --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md @@ -0,0 +1,354 @@ +# GroupInstanceReferenceImpl: Reactive Remote-Audio-SSRC Discovery + +**Date:** 2026-05-01 +**Status:** Approved (design only — no implementation yet) +**Scope:** `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` and adjacent test wiring. + +## Problem + +`GroupInstanceReferenceImpl` (PeerConnection-based group call client) currently learns about remote audio SSRCs **only** from a `colibriClass=ActiveAudioSsrcs` data-channel message broadcast by the test-bench Pion SFU (`tgcalls/tools/go_sfu/sfu.go`). The real Telegram SFU does not send that message — `GroupInstanceCustomImpl::receiveDataChannelMessage` only handles `SenderVideoConstraints` and `DebugMessage`. CustomImpl discovers SSRCs reactively from raw RTP via `GroupNetworkManager` → `receiveUnknownSsrcPacket` → `maybeRequestUnknownSsrc` → `_requestMediaChannelDescriptions`. ReferenceImpl has no equivalent path; in real calls every remote audio packet is silently dropped (or routed to mid=0's unsignaled handler with no application visibility). + +The fix must: +- Surface every previously-unseen remote audio SSRC to ReferenceImpl's internal logic, ideally on the first frame. +- Drive the addition of a recvonly audio transceiver for that SSRC. +- Match CustomImpl's app-facing contract — the application sees the same `_requestMediaChannelDescriptions(ssrcs, completion)` callback it already implements. +- Use a single discovery mechanism in both real and test environments (the test SFU's `ActiveAudioSsrcs` broadcast becomes obsolete and is removed; see "Removing `ActiveAudioSsrcs`" below). + +## Approach: one `GRAudioFrameTransformer` installed on every audio receiver + +WebRTC exposes `RtpReceiverInterface::SetDepacketizerToDecoderFrameTransformer(FrameTransformerInterface*)`. The transformer's `Transform(frame)` takes ownership of a `std::unique_ptr` and there is **no requirement that it call `OnTransformedFrame` synchronously** — the design is explicitly async. The transformer can hold the frame for arbitrarily long; the audio pipeline simply waits. + +We install **the same `GRAudioFrameTransformer` instance on every audio receiver** in this `GroupInstanceReferenceInternal`: + +- **mid=0 (sendrecv outgoing audio)** — explicitly via `_outgoingAudioTransceiver->receiver()->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer)` in `start()`. This lands as both the per-receiver transformer and (because mid=0's receive side has `signaled_ssrc=nullopt`) the channel's `unsignaled_frame_transformer_` — meaning any unsignaled stream created later for an unknown SSRC also gets it. +- **Each recvonly audio transceiver** (added by the discovery flow) — explicitly via the same call when the transceiver is added in `renegotiate()`. This is structurally required for the upcoming e2e-decrypt fix (every receiver needs the decrypt hook); attaching it now too is free and removes our reliance on stream-promotion implicitly carrying the transformer along. + +The transformer carries the per-SSRC state machine (described below). It also carries a `decryptHook` callable (initially null) that the e2e PR will wire to `descriptor.e2eEncryptDecrypt`. Today the hook just passes the frame through unchanged; tomorrow it decrypts before `OnTransformedFrame`. + +### Why install on every receiver explicitly + +If we relied solely on `unsignaled_frame_transformer_`, the transformer would be carried onto a recvonly transceiver only via the stream-promotion path (`webrtc_voice_engine.cc:2258-2266`: `MaybeDeregisterUnsignaledRecvStream` keeps the stream object intact, transformer attached). That's correct *today* but it's an internal-WebRTC behavior we'd be pinning to. Once we explicitly attach to each recvonly receiver we own the lifecycle: the transformer is on the stream because we put it there, regardless of how WebRTC's voice engine handles the unsignaled→signaled transition. + +For the buffer flush specifically: the buffered frames were captured while the SSRC was on mid=0's unsignaled-fallback path. When we later install the transformer on the recvonly receiver R' for the same SSRC, the channel's stream (now promoted in place) keeps the same transformer reference (same instance, same pointer) — so `releaseSsrc` flushes through `_perSsrcSinks[ssrc]` (the sink callback WebRTC registered when the stream first appeared) and frames land in that one stream's decoder. + +### Tap behavior + +For each SSRC the transformer maintains an `Entry { state, buffer, firstFrameTimeMs }` with `state ∈ { kBuffering, kDrained }`. + +`Transform(frame)` (worker thread): +1. Acquire the mutex. +2. Look up the entry for `frame->GetSsrc()`. +3. If absent and we're below `kMaxConcurrentBufferedSsrcs`: insert with `kBuffering` state, post `_onNewSsrc(ssrc)` to the media thread (outside the lock), buffer the frame. +4. If `kBuffering`: append to buffer (drop oldest if `buffer.size() >= kMaxBufferedFramesPerSsrc`). +5. If `kDrained`: release the lock, then call `OnTransformedFrame(std::move(frame))`. Live audio flows through. + +`releaseSsrc(uint32_t ssrc)` (media thread, called from `onRenegotiationComplete`): +1. Acquire the mutex. +2. Locate the entry; if absent or already `kDrained`, return. +3. Move the deque out, mark `state = kDrained`, release the mutex. +4. Iterate the moved deque calling `OnTransformedFrame(std::move(frame))` on each. Performed outside the lock to avoid re-entrant deadlocks. + +The order of operations matters: marking `kDrained` *before* releasing the lock guarantees that any concurrent `Transform()` either (a) sees `kBuffering` and buffers — but its frame is lost because we already drained the FIFO, or (b) sees `kDrained` and passes through. Case (a) is a real one-frame-loss race window. We accept it: at 20 ms Opus that's a single packet of audio, inaudible, and overwhelmingly unlikely (the window is the few microseconds between unlocking and starting the drain loop). + +### Failure mode + +If `releaseSsrc(X)` is not called within `kSsrcDiscoveryTimeoutMs = 1000` ms (renegotiation failed or app declined the SSRC), an in-line eviction inside `Transform()` drops the buffer and **leaves the entry in `kBuffering` with empty FIFO**. Subsequent frames continue to attempt to buffer (and immediately drop on the FIFO cap = 0 / re-eviction), so the participant remains silent until the entry is cleared. Acceptable — the same outcome as the pure-drop alternative would have produced. + +### Net behavior + +- **Audible continuity for new participants.** Buffered frames flush into the same stream that carries future audio. NetEQ sees the buffered burst as one large jitter-buffer fill followed by normal-paced packets — same shape as recovering from a network glitch. May produce a brief audible artifact during the burst (NetEQ may accelerate or reorder) but no silence. +- **No orphaned `AudioReceiveStream`.** Stream is promoted in place; one stream per SSRC. +- **Tap stays in the live path.** Per-frame: one mutex, one map lookup, one `OnTransformedFrame`. Hot but cheap. + +CustomImpl already uses `FrameTransformer` (for E2E encryption) — the pattern is established in this codebase. Pass-through transformers also work in WebRTC (the `OnTransformedFrame` callback is the only contract). + +### Why not the alternatives + +- **`OnTrack` for unsignaled audio:** PeerConnection's "default" handler creates one default track. Multi-SSRC behavior is murky; fragile. +- **`PeerConnection::GetStats()` polling:** Works but adds 100–250 ms of discovery latency per new participant (audio drops during the window) and the stats walk is non-trivial. +- **Tap on a custom socket factory:** Requires re-implementing SRTP decrypt and RTP parsing. Too invasive. + +## Components + +``` + ┌────────────────────────────┐ + incoming RTP (any SSRC) ───▶ │ PeerConnection BUNDLE │ + │ demuxer (SSRC-keyed) │ + └─────────────┬──────────────┘ + │ + ┌───────────────────┼─────────────────────┐ + │ │ │ + signaled SSRC X signaled SSRC Y unknown / catch-all + (recvonly mid=N1) (recvonly mid=N2) (sendrecv mid=0) + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ + │ AudioTrack │ │ AudioTrack │ │ GRSsrcTapTransformer │ + │ + level sink │ │ + level sink │ │ (Transform → notify │ + └──────────────┘ └──────────────┘ │ + OnTransformedFrame)│ + └──────────┬───────────┘ + │ (worker thread) + │ + ▼ + PostTask to media thread + │ + ▼ + handleDiscoveredAudioSsrc(ssrc) + │ + ▼ + (existing) renegotiate() + + _requestMediaChannelDescriptions +``` + +### `GRAudioFrameTransformer` (new, anonymous-namespace class in `GroupInstanceReferenceImpl.cpp`) + +```cpp +class GRAudioFrameTransformer : public webrtc::FrameTransformerInterface { +public: + using SsrcCallback = std::function; + // Hook for the future e2e-decrypt fix. Called per frame on the worker + // thread before OnTransformedFrame. Today: identity (passes the frame + // through unchanged). The e2e PR will assign it to a closure that + // unwraps the descriptor.e2eEncryptDecrypt envelope. + using DecryptHook = std::function; + + GRAudioFrameTransformer(SsrcCallback onNewSsrc, + DecryptHook decrypt, // may be nullptr + rtc::Thread* mediaThread); + + // Called from ReferenceImpl on the media thread after onRenegotiationComplete + // confirms a recvonly transceiver now owns `ssrc`. Drains the per-SSRC + // FIFO into OnTransformedFrame in arrival order; subsequent frames for + // `ssrc` flow through unchanged (kDrained = live passthrough). + void releaseSsrc(uint32_t ssrc); + + // FrameTransformerInterface + void Transform(std::unique_ptr frame) override; + void RegisterTransformedFrameCallback(rtc::scoped_refptr) override; + void RegisterTransformedFrameSinkCallback(rtc::scoped_refptr, uint32_t ssrc) override; + void UnregisterTransformedFrameCallback() override; + void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) override; + +private: + enum class SsrcState { kBuffering, kDrained }; + + struct Entry { + SsrcState state = SsrcState::kBuffering; + std::deque> buffer; + int64_t firstFrameTimeMs = 0; // for timeout eviction + }; + + void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu); // called inside Transform + + SsrcCallback _onNewSsrc; + rtc::Thread* _mediaThread; // used only as identity for assertions + + webrtc::Mutex _mu; + rtc::scoped_refptr _broadcastSink RTC_GUARDED_BY(_mu); + std::map> _perSsrcSinks RTC_GUARDED_BY(_mu); + std::map _entries RTC_GUARDED_BY(_mu); + + static constexpr int64_t kSsrcDiscoveryTimeoutMs = 1000; + static constexpr size_t kMaxBufferedFramesPerSsrc = 60; // ~1.2s at 20ms Opus + static constexpr size_t kMaxConcurrentBufferedSsrcs = 64; // upper bound on memory +}; +``` + +#### `Transform` (worker thread) + +1. `ssrc = frame->GetSsrc()`. Acquire `_mu`. +2. `evictExpired_n()` — walk `_entries`; for any whose `firstFrameTimeMs` is older than `kSsrcDiscoveryTimeoutMs` and still `kBuffering`, clear the buffer (entry stays so we don't re-notify; subsequent frames re-evict and stay silent until the entry is removed by an explicit application action — acceptable failure mode). +3. Look up the entry for `ssrc`: + - **Not present and `_entries.size() >= kMaxConcurrentBufferedSsrcs`** → drop frame, no notify (overflow protection against pathological SFU behavior). + - **Not present otherwise** → insert `Entry{ kBuffering, {}, now() }`, push frame, **release lock**, invoke `_onNewSsrc(ssrc)` (which posts to media thread → `handleDiscoveredAudioSsrc(ssrc)`). + - **Present and `kBuffering`**: + - If `entry.buffer.size() >= kMaxBufferedFramesPerSsrc` → drop the oldest buffered frame (FIFO bounded). + - Push `std::move(frame)` to the back of `entry.buffer`. + - **Present and `kDrained`** → take a local copy of the appropriate sink callback (per-SSRC if registered, else broadcast), **release lock**, call `sink->OnTransformedFrame(std::move(frame))`. This is the live-audio path after `releaseSsrc` has fired. + +The mutex is held only across the lookup and entry/buffer mutation. Both branches that emit through `OnTransformedFrame` (`kDrained` in `Transform`, and `releaseSsrc`) drop the lock before the call to avoid re-entrant deadlocks — WebRTC may synchronously schedule decoder work in `OnTransformedFrame` that calls back into related machinery. + +#### `releaseSsrc(uint32_t ssrc)` (media thread) + +1. Acquire `_mu`. Locate the entry; if missing or already `kDrained`, return. +2. Find the appropriate sink callback: per-SSRC if registered, else broadcast. +3. Mark `entry.state = kDrained` and `std::move` the deque out. **Marking `kDrained` before releasing the lock** is what guarantees no concurrent `Transform()` can buffer a frame that we then fail to flush. +4. Release `_mu`. Iterate the moved deque calling `sink->OnTransformedFrame(std::move(frame))` on each. + +#### `Register*` / `Unregister*` + +Store the callbacks under `_mu`. The per-SSRC `RegisterTransformedFrameSinkCallback` is invoked by WebRTC the first time a new SSRC arrives at this transformer; we hold it so `releaseSsrc` can dispatch through it. `Unregister*` clears. + +### Hook points in `GroupInstanceReferenceInternal` + +In `start()`, after `_outgoingAudioTransceiver` is created (mid=0): + +```cpp +auto weak = std::weak_ptr(shared_from_this()); +auto threads = _threads; +_audioFrameTransformer = rtc::make_ref_counted( + /*onNewSsrc=*/[weak, threads](uint32_t ssrc) { + threads->getMediaThread()->PostTask([weak, ssrc]() { + if (auto strong = weak.lock()) { + strong->handleDiscoveredAudioSsrc(ssrc); + } + }); + }, + /*decrypt=*/nullptr, // wired in the e2e PR + /*mediaThread=*/_threads->getMediaThread()); +_outgoingAudioTransceiver->receiver()->SetDepacketizerToDecoderFrameTransformer( + _audioFrameTransformer); +``` + +In `renegotiate()`, immediately after `_peerConnection->AddTransceiver(MEDIA_TYPE_AUDIO, recvonly)` succeeds for an SSRC discovered through the tap, attach the same transformer to the new receiver: + +```cpp +auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); +if (result.ok()) { + info.transceiver = result.value(); + info.transceiver->receiver() + ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); +} +``` + +In `onRenegotiationComplete()`, after `wireRemoteAudioLevelSinks()` runs, release any SSRCs whose recvonly transceiver just became active: + +```cpp +if (_audioFrameTransformer) { + for (auto& [ssrc, info] : _remoteSsrcs) { + if (info.transceiver && info.transceiver->mid().has_value()) { + // Idempotent: releaseSsrc no-ops on entries already drained. + _audioFrameTransformer->releaseSsrc(ssrc); + } + } +} +``` + +### `handleDiscoveredAudioSsrc(uint32_t ssrc)` (new, on media thread) + +This is the **only** entry point for adding a remote audio SSRC. It accumulates the SSRC into `_remoteSsrcs` and **schedules** a single coalesced renegotiation rather than firing one immediately: + +```cpp +void handleDiscoveredAudioSsrc(uint32_t ssrc) { + if (ssrc == 0) return; + if (ssrc == _outgoingSsrc) return; // our own + if (_remoteSsrcs.count(ssrc) > 0) return; // already known + + std::string mid = std::to_string(_nextMid++); + RemoteSsrcInfo info; + info.mid = mid; + _remoteSsrcs.emplace(ssrc, std::move(info)); + + if (_requestMediaChannelDescriptions) { + _requestMediaChannelDescriptions({ssrc}, [](auto&&) { /* fire-and-forget */ }); + } + scheduleDiscoveryRenegotiation(); +} +``` + +### Removing `ActiveAudioSsrcs` + +With the tap as the canonical discovery path, the test SFU's `ActiveAudioSsrcs` broadcast becomes redundant — and continuing to ship it would mean test runs exercise a code path that doesn't exist in production. Three deletions: + +1. `tools/go_sfu/sfu.go` — remove the `colibriClass=ActiveAudioSsrcs` broadcast (the message construction at `sfu.go:987` and any per-participant-join trigger that emits it). Remove the `ColibriClass`/`Ssrcs` JSON struct used solely for this purpose. +2. `GroupInstanceReferenceImpl.cpp` — delete `handleActiveAudioSsrcs(json)` and the `if (colibriClass == "ActiveAudioSsrcs") { ... }` dispatch in `onDataChannelMessage`. The dispatch becomes "forward to app callback if set" only (currently forwards regardless, so just drop the colibri branch). +3. `tools/cli/group_participant.cpp` — no change required. The CLI already only reacts to `ActiveVideoSsrcs` in its `dataChannelMessageReceived`; `ActiveAudioSsrcs` was never observed by the test app, only consumed internally by ReferenceImpl. + +Verification that removal is safe: `grep -r ActiveAudioSsrcs` across the repo currently returns hits only in (1) the SFU emitter, (2) the ReferenceImpl handler we're deleting, and (3) documentation/CLAUDE.md files (which we update as part of the change). CustomImpl never references it; iOS app code never references it; the test CLI never references it. + +Removed-SSRC handling: the deleted `handleActiveAudioSsrcs` also processed *removals* (SFU told us a participant left). After deletion, ReferenceImpl no longer reacts to participant departures via the data channel. This matches CustomImpl's behavior — CustomImpl also has no remove path; SSRCs simply go silent and the application removes them from the participant list via MTProto. Recvonly transceivers stay in the SDP indefinitely, which is a small per-call leak but not a correctness issue. (If this proves to be a problem in long-running calls, a future change can add a "remove if no audio for N seconds" sweep.) + +### `scheduleDiscoveryRenegotiation()` — debounce window + +A 250 ms delayed task on the media thread coalesces a burst of discoveries into one renegotiation. The existing `renegotiate()` already iterates `_remoteSsrcs` and adds a recvonly transceiver for any entry that doesn't have one yet, so all SSRCs accumulated during the delay window are picked up in a single offer/answer cycle. + +```cpp +static constexpr int kDiscoveryRenegotiationDelayMs = 250; + +void scheduleDiscoveryRenegotiation() { + if (_discoveryRenegotiationScheduled) return; + _discoveryRenegotiationScheduled = true; + auto weak = std::weak_ptr(shared_from_this()); + _threads->getMediaThread()->PostDelayedTask( + [weak]() { + auto strong = weak.lock(); + if (!strong) return; + strong->_discoveryRenegotiationScheduled = false; + strong->renegotiate(); + }, + webrtc::TimeDelta::Millis(kDiscoveryRenegotiationDelayMs)); +} +``` + +**Layering with existing serialization.** The debounce sits on top of `renegotiate()`'s existing `_isRenegotiating` / `_pendingRenegotiation` guard. Three regimes: + +1. **No renegotiation in flight when the timer fires:** `renegotiate()` runs immediately, picks up all queued SSRCs. +2. **A renegotiation is already in flight (e.g., from `setRequestedVideoChannels`):** the queued `renegotiate()` sets `_pendingRenegotiation`, runs after the in-flight cycle completes — picks up everything including the new SSRCs. +3. **More SSRCs discovered while the timer is pending:** `_discoveryRenegotiationScheduled == true` → no new task scheduled, the SSRC just lands in `_remoteSsrcs` and joins the upcoming batch. + +Result: at most one discovery-sourced renegotiation per 250 ms, regardless of arrival burst size. + +**Audible-gap implication.** New joiners are silent during the debounce window because their packets land in mid=0's catch-all (which still decodes them and feeds the AudioMixer for playback) but their per-receiver `GRAudioLevelSink` doesn't exist yet, so the speaking-indicator UI shows no level until the renegotiation completes (~250 ms + offer/answer round-trip). Audio is heard, the indicator just lags. Acceptable. + +**Stop semantics.** On `stop()`, the queued task may still fire. The `weak_ptr` guard makes the lambda a no-op if the internal has been destroyed. The `_isRenegotiating` flag inside `renegotiate()` also bails if `_peerConnection` has been closed. + +**Behavior change in test mode:** the existing `handleActiveAudioSsrcs` calls `_requestMediaChannelDescriptions` once per batch with all new SSRCs. After refactor, it issues N single-SSRC calls. This is acceptable: requests are local fire-and-forget callbacks into the app and bear no network cost in the CLI test bench. If the iOS app's implementation later turns out to be sensitive to call frequency, `handleActiveAudioSsrcs` can re-aggregate by collecting the SSRCs first and issuing one batched request after the per-SSRC `handleDiscoveredAudioSsrc` calls — but the simpler version is the starting point. + +## Data flow + +1. SFU starts forwarding remote audio for SSRC X (no signaling messages). +2. PeerConnection demuxes the first packet for SSRC X — no recvonly transceiver matches → routed to mid=0's catch-all receiver. The voice channel creates an unsignaled `WebRtcAudioReceiveStream` for X with our tap as the depacketizer-to-decoder transformer. `Transform(frame1)` is called. +3. Tap finds no entry for X → inserts `{ kBuffering, [frame1], now() }` → posts `_onNewSsrc(X)` to the media thread → `handleDiscoveredAudioSsrc(X)`. +4. `handleDiscoveredAudioSsrc` adds X to `_remoteSsrcs`, fires `_requestMediaChannelDescriptions({X}, ...)`, calls `scheduleDiscoveryRenegotiation()` (debounce 250 ms). +5. Subsequent packets for X arrive at the tap (state still `kBuffering`) → appended to the same FIFO (oldest dropped if `>= kMaxBufferedFramesPerSsrc`). +6. After 250 ms the debounce timer fires `renegotiate()`, which adds a recvonly transceiver bound to mid=`_nextMid++` for every entry in `_remoteSsrcs` that lacks one. `SetRemoteDescription` propagates SSRC X into the recvonly m-line; `WebRtcVoiceReceiveChannel::AddRecvStream(X)` finds X in `unsignaled_recv_ssrcs_` and **promotes** the existing stream in place (no new stream created; tap transformer remains attached). +7. `onRenegotiationComplete` runs: `wireRemoteAudioLevelSinks()` attaches a `GRAudioLevelSink` to the new recvonly receiver's track; for each SSRC whose transceiver now has a mid, ReferenceImpl calls `_ssrcTapTransformer->releaseSsrc(ssrc)`. +8. `releaseSsrc(X)` marks the entry `kDrained` under the lock, moves the FIFO out, releases the lock, and calls `OnTransformedFrame` for each buffered frame in arrival order. The promoted stream's decoder receives the burst; NetEQ buffers and plays out at natural rate. +9. Subsequent packets for X reach `Transform()`; the entry is `kDrained` → tap calls `OnTransformedFrame` directly. Live audio flows. The per-receiver `GRAudioLevelSink` (attached in step 7) reads real levels from the post-decode PCM stream. + +**Failure mode (timeout).** If `releaseSsrc(X)` is not called within `kSsrcDiscoveryTimeoutMs = 1000` ms (renegotiation failed or app declined the SSRC), the in-line eviction in `Transform` clears the buffer for X. The entry stays in `kBuffering` so we don't re-notify, but no future frames are forwarded — the participant goes silent. Same outcome as the pure-drop alternative. + +## Threading + +- `Transform` runs on PeerConnection's worker thread. Hot path — must be cheap. Per call: one mutex acquisition, a deque push (or drop), an O(N_active_SSRCs) eviction walk that is cheap (typically 0–2 items per call). On first-sight SSRC the worker thread also posts to the media thread. +- `releaseSsrc` runs on the media thread (called from `onRenegotiationComplete`). Acquires the same mutex, moves the FIFO out under lock, then releases the lock and calls `OnTransformedFrame` outside the lock to avoid re-entrant deadlocks (WebRTC's `OnTransformedFrame` may call back into the transformer infrastructure). +- `OnTransformedFrame` itself: WebRTC's contract does not pin it to a specific thread; calling from the media thread is legal. WebRTC dispatches the actual depacketize/decode onto the worker thread internally. +- `handleDiscoveredAudioSsrc` runs on the media thread. Existing renegotiation machinery is media-thread-safe. +- Lifetime: the transformer is owned by `_ssrcTapTransformer` (member, `scoped_refptr`). `weak_ptr` capture in the SSRC callback prevents use-after-free during teardown. WebRTC clears the transformer when the receiver is destroyed (PeerConnection close); any frames still in the FIFO at that point are released by the deque destructor (the underlying `TransformableFrameInterface` instances are owned `unique_ptr`s and clean up automatically). + +## Testing + +After removing `ActiveAudioSsrcs`, the tap is the only discovery path in every mode — test and real. The existing CLI test bench validates it without modification: + +- All-Reference, no mute: each ReferenceImpl peer must discover the others via the tap, add recvonly transceivers, and report `level ≥ 0.05` (current invariant in `validateGroupState`). +- Mixed (CustomImpl + ReferenceImpl), no mute: same invariant from both sides. +- Mute scenarios: muted peers must still be discovered (their packets carry encoded silence; the tap fires on first packet) and their `level` must read 0 (existing muted-peer invariant from the previous fix). + +If any of these regress, the tap is broken — which is exactly the coverage we want. + +No new CLI flags or SFU exports needed; the previous draft's `--suppress-active-audio-ssrcs` is moot. + +## Out of scope (this design) + +- **E2E `e2eEncryptDecrypt` wiring** is a separate fix, but this design pre-installs the surface it needs: `GRAudioFrameTransformer::DecryptHook` is a constructor parameter (nullptr today). The e2e PR captures `descriptor.e2eEncryptDecrypt` and passes a closure that decrypts the frame's `GetData()` and writes back via `SetData()` before the transformer calls `OnTransformedFrame`. No further structural changes — the transformer is already attached to every audio receiver. +- SSRC>int31 join-payload masking (separate fix). +- Push-style discovery via a new `GroupInstanceInterface::addIncomingAudioSsrcs(...)` method. May be added later but is not needed to fix the immediate problem. +- Video SSRC discovery — handled by the existing app-facing `dataChannelMessageReceived` callback for `ActiveVideoSsrcs` (and via `setRequestedVideoChannels` from MTProto data on iOS). The same per-receiver transformer pattern would extend to incoming video for video e2e, but that's outside the audio scope here. + +## Risks + +- **Buffer-flush correctness.** The tap holds frames until `releaseSsrc` fires. If the call is missed (bug in `onRenegotiationComplete`, race with `stop()`), the timeout clears the buffer at 1 s and the participant goes silent. The CLI integration tests catch this end-to-end: with `ActiveAudioSsrcs` removed, the tap is the *only* discovery path, so the existing `receivedAudio ≥ 0.05` invariant validates the full chain. +- **Tap-passthrough correctness.** Because the tap transformer remains attached after stream promotion, *every* live frame for an SSRC also passes through `Transform()` → `kDrained` branch → `OnTransformedFrame`. If the `kDrained` branch is broken or skipped, every audio frame for every promoted SSRC is silently dropped. Same CLI test coverage applies: the moment passthrough breaks, no peer hears anyone. +- **NetEQ jitter-buffer burst on flush.** `releaseSsrc` flushes up to ~1 s of audio (`kMaxBufferedFramesPerSsrc` × 20 ms = 1.2 s) into the receive stream in one tight loop. NetEQ sees this as a jitter-buffer fill spike. It will normally play out at the natural 50 fps rate, but the burst may exceed `audio_jitter_buffer_max_packets_` and trigger acceleration, deletion, or PLC artifacts. Worst case: a brief audio glitch when a new participant first speaks. Acceptable; mitigated by setting `kMaxBufferedFramesPerSsrc` aggressively low (e.g., 30 frames = 600 ms) if the artifact proves audible. +- **Race window during release.** Marking `kDrained` while still holding the lock prevents concurrent `Transform()` from buffering a doomed frame. There is still a microsecond between unlock and the start of the drain loop where a `Transform()` could beat `releaseSsrc` to the live-passthrough path — but the result is just the new frame arriving at the decoder *before* the buffered backlog. NetEQ reorders by RTP timestamp; harmless. +- **Renegotiation storm.** Mitigated by the 250 ms debounce in `scheduleDiscoveryRenegotiation()`: a burst of N SSRCs in the window collapses to one renegotiation. The existing `_isRenegotiating` / `_pendingRenegotiation` flags handle the case where another renegotiation source (e.g., `setRequestedVideoChannels`) is concurrently in flight. The first-sight check in the tap prevents duplicate per-SSRC scheduling. +- **Memory-pressure cap.** Worst-case buffered audio: `kMaxConcurrentBufferedSsrcs` × `kMaxBufferedFramesPerSsrc` × ~80 bytes/frame ≈ 64 × 60 × 80 = 308 KB. Both bounds are deliberately conservative — a misbehaving SFU sending unique SSRCs per packet hits the SSRC cap and drops further new ones rather than allocating unbounded memory. +- **Stream promotion is still load-bearing for buffered audio.** Live frames flow correctly because we explicitly install the transformer on each recvonly receiver — that path is no longer dependent on internal WebRTC behavior. *Buffered frames* still rely on the unsignaled→signaled stream promotion: the per-SSRC `TransformedFrameCallback` we got at first-sight is the one we replay through. If a future WebRTC update breaks promotion (constructs a new signaled stream and orphans the unsignaled one), `releaseSsrc` would dispatch frames into the orphan and they'd never decode. The CLI tests catch this — buffered audio would be silent during the first 250–500 ms of every new participant, which the audio-level invariant will flag in mute-style tests if extended to assert on first-second levels. + +## Files touched (anticipated) + +- `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` — add `GRAudioFrameTransformer` (per-SSRC FIFO + `releaseSsrc` + `decryptHook` callable initialized to nullptr); construct + install on mid=0's receiver in `start()`; install on each new recvonly receiver in `renegotiate()`; add `handleDiscoveredAudioSsrc` and `scheduleDiscoveryRenegotiation`; **delete `handleActiveAudioSsrcs` and its dispatch in `onDataChannelMessage`**; call `releaseSsrc` from `onRenegotiationComplete` for each newly-mid-assigned SSRC; add `_audioFrameTransformer` (`scoped_refptr`) and `_discoveryRenegotiationScheduled` members. +- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` — **delete `ActiveAudioSsrcs` broadcast** (message construction at `sfu.go:987` and any per-join trigger). +- `submodules/TgVoipWebrtc/CLAUDE.md` and `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — update to reflect that ReferenceImpl discovers SSRCs via a `FrameTransformer` tap on mid=0; remove `ActiveAudioSsrcs`/`SetDefaultRawAudioSink` references. +- No iOS-side changes (`OngoingCallThreadLocalContext.mm` etc.) — the existing `requestMediaChannelDescriptions` callback already supports the discovery path. +- No CLI changes (`tools/cli/main.cpp`, `group_mode.cpp/.h`) — the existing tests validate the tap directly. diff --git a/submodules/TgVoipWebrtc/CLAUDE.md b/submodules/TgVoipWebrtc/CLAUDE.md index 7766eb1d60..27994a67c2 100644 --- a/submodules/TgVoipWebrtc/CLAUDE.md +++ b/submodules/TgVoipWebrtc/CLAUDE.md @@ -158,7 +158,7 @@ GroupInstanceReferenceImpl ├── sendonly video transceiver (outgoing H264 simulcast, SDP-munged SSRCs) ├── recvonly audio transceivers (one per remote SSRC, added dynamically) ├── recvonly video transceivers (one per remote endpoint, added dynamically) - └── data channel ("data", for ActiveAudioSsrcs / ActiveVideoSsrcs) + └── data channel ("data", for ActiveVideoSsrcs and Colibri video constraints) ``` ### How It Differs from CustomImpl @@ -167,9 +167,9 @@ GroupInstanceReferenceImpl |--------|-----------|---------------| | Transport | Manual ICE/DTLS/SRTP via GroupNetworkManager | WebRTC PeerConnection | | SDP | None (custom JSON protocol) | Local SDP construction, translates to/from JSON | -| SSRC discovery | `unknownSsrcPacketReceived` on raw RTP | `ActiveAudioSsrcs`/`ActiveVideoSsrcs` data channel messages from SFU | +| SSRC discovery | `unknownSsrcPacketReceived` on raw RTP | Audio: `GRAudioFrameTransformer` on mid=0's unsignaled receiver. Video: `ActiveVideoSsrcs` data channel message from SFU | | Audio channels | Manual `IncomingAudioChannel` per SSRC | PeerConnection recvonly transceivers | -| Audio levels | RTP header extension parsing | Synthetic levels based on known SSRCs | +| Audio levels | RTP header extension parsing | Per-receiver `GRAudioLevelSink` reading real PCM levels | | Video outgoing | Manual `cricket::VideoChannel` with direct SSRC control | PeerConnection sendonly transceiver + SDP munging for simulcast SSRCs | | Video incoming | Manual `IncomingVideoChannel` per endpoint | PeerConnection recvonly transceivers with SSRCs in answer | | Video decode | Manual decoder lifecycle | PeerConnection handles internally | @@ -187,11 +187,17 @@ GroupInstanceReferenceImpl ### Dynamic Participant Handling -**Audio:** -1. SFU sends `{"colibriClass":"ActiveAudioSsrcs","ssrcs":[54321,98765]}` over data channel -2. Client diffs against known SSRCs -3. New SSRCs: add recvonly audio transceiver → renegotiate (new offer + constructed answer mirroring offer mids) -4. Removed SSRCs: clean up from tracking map +**Audio (per-receiver frame transformer):** +1. The first packet for an unknown SSRC X reaches mid=0's receiver — PeerConnection's catch-all for unsignaled audio. The voice channel creates a `WebRtcAudioReceiveStream` for X and attaches `GRAudioFrameTransformer` (registered as `unsignaled_frame_transformer_` on mid=0). +2. The transformer's `Transform(frame)` reads `frame->GetSsrc() = X`, sees X for the first time, **buffers the frame** in a per-SSRC FIFO, and posts the SSRC to the media thread. +3. `handleDiscoveredAudioSsrc(X)` inserts X into `_remoteSsrcs` with a fresh mid, fires `_requestMediaChannelDescriptions({X}, ...)` (matches CustomImpl's contract), and calls `scheduleDiscoveryRenegotiation()` (250 ms debounce). +4. After the debounce, `renegotiate()` adds a recvonly audio transceiver bound to mid=`_nextMid++` for every entry in `_remoteSsrcs` that doesn't have one. `buildRemoteAnswer` includes X on the new m-line; `WebRtcVoiceReceiveChannel::AddRecvStream(X)` PROMOTES the existing unsignaled stream in place (`webrtc_voice_engine.cc:2258-2266`) — the transformer stays attached. +5. `onRenegotiationComplete` runs `wireRemoteAudioLevelSinks()` (attaches `GRAudioLevelSink` per receiver), then calls `_audioFrameTransformer->releaseSsrc(ssrc)` for every SSRC whose transceiver now has a mid. The transformer drains the per-SSRC FIFO via `OnTransformedFrame` so the buffered audio plays without a startup gap. +6. Subsequent live frames for X take the transformer's `kDrained` branch (immediate passthrough). The per-receiver `GRAudioLevelSink` reads real PCM levels. + +The `colibriClass=ActiveAudioSsrcs` data-channel mechanism (test-SFU only) was removed; the tap is the single audio-discovery path. Removed-SSRC handling is the same as CustomImpl: stale recvonly transceivers stay in the SDP indefinitely; participant departures are tracked at the application layer (MTProto). + +The transformer is installed once on mid=0's receiver only — re-installing on the recvonly receivers triggers `Register{Sink,}TransformedFrameCallback` re-runs that overwrite valid registrations and misroute frames. Stream promotion keeps the single instance valid for every signaled SSRC. **Video:** 1. SFU sends `ActiveVideoSsrcs` over data channel → forwarded to app via `dataChannelMessageReceived` diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index 454718339e..c502af7a2e 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit 454718339e9b8c9dad7effcd94a3f5e534043537 +Subproject commit c502af7a2e87595f1fbb8b98a4ff6942691f85cb From f25d0776c7ffa746e8b61d6a676dc048c305376f Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:19:17 +0200 Subject: [PATCH 60/69] Visual improvements --- .../ContainedViewLayoutTransition.swift | 27 ++++++++++++++----- .../Sources/ChatMessageBubbleItemNode.swift | 4 +-- .../Sources/ChatMessageItemCommon.swift | 2 +- .../Sources/ChatMessageItemView.swift | 4 +-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 8ec45f3bb9..17abeb224c 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -421,7 +421,7 @@ public extension ContainedViewLayoutTransition { } case let .animated(duration, curve): let previousBounds: CGRect - if beginWithCurrentState, layer.animation(forKey: "position") != nil, let presentation = layer.presentation() { + if beginWithCurrentState, layer.animation(forKey: "bounds") != nil, let presentation = layer.presentation() { previousBounds = presentation.bounds } else { previousBounds = layer.bounds @@ -464,7 +464,7 @@ public extension ContainedViewLayoutTransition { } } - func updatePosition(layer: CALayer, position: CGPoint, force: Bool = false, completion: ((Bool) -> Void)? = nil) { + func updatePosition(layer: CALayer, position: CGPoint, force: Bool = false, beginFromCurrentState: Bool = false, completion: ((Bool) -> Void)? = nil) { if layer.position.equalTo(position) && !force { completion?(true) } else { @@ -476,7 +476,22 @@ public extension ContainedViewLayoutTransition { completion(true) } case let .animated(duration, curve): - let previousPosition = layer.position + let previousPosition: CGPoint + if beginFromCurrentState, let animationKeys = layer.animationKeys(), animationKeys.contains(where: { key in + guard let animation = layer.animation(forKey: key) as? CAPropertyAnimation else { + return false + } + if animation.keyPath == "position" { + return true + } else { + return false + } + }) { + previousPosition = layer.presentation()?.position ?? layer.position + } else { + previousPosition = layer.position + } + layer.position = position layer.animatePosition(from: previousPosition, to: position, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, completion: { result in if let completion = completion { @@ -2772,7 +2787,7 @@ public final class ControlledTransition { } public func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) { - self.transition.updatePosition(layer: layer, position: position, completion: completion) + self.transition.updatePosition(layer: layer, position: position, beginFromCurrentState: true, completion: completion) } public func updateTransform(layer: CALayer, transform: CATransform3D, completion: ((Bool) -> Void)?) { @@ -2792,11 +2807,11 @@ public final class ControlledTransition { } public func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) { - self.transition.updateBounds(layer: layer, bounds: bounds, completion: completion) + self.transition.updateBounds(layer: layer, bounds: bounds, beginWithCurrentState: true, completion: completion) } public func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) { - self.transition.updateFrame(layer: layer, frame: frame, completion: completion) + self.transition.updateFrame(layer: layer, frame: frame, beginWithCurrentState: true, completion: completion) } public func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 36f8c6a08d..2195dfb30a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -2278,7 +2278,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } if !bubbleReactions.reactions.isEmpty && !item.presentationData.isPreview { if incoming { - bottomNodeMergeStatus = .Both + bottomNodeMergeStatus = .Left } else { bottomNodeMergeStatus = .Right } @@ -3814,7 +3814,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } var forceBackgroundSide = false - if actionButtonsSizeAndApply != nil || reactionButtonsSizeAndApply != nil { + if actionButtonsSizeAndApply != nil { forceBackgroundSide = true } else if case .semanticallyMerged = updatedMergedTop { forceBackgroundSide = true diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift index 7a0c2f956b..3b2669fc44 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/ChatMessageItemCommon.swift @@ -138,7 +138,7 @@ public struct ChatMessageItemLayoutConstants { public static var compact: ChatMessageItemLayoutConstants { let bubble = ChatMessageItemBubbleLayoutConstants(edgeInset: 3.0, defaultSpacing: 2.0 + UIScreenPixel, mergedSpacing: 0.0, maximumWidthFill: ChatMessageItemWidthFill(compactInset: 36.0, compactWidthBoundary: 500.0, freeMaximumFillFactor: 0.85), minimumSize: CGSize(width: 40.0, height: 35.0), contentInsets: UIEdgeInsets(top: 0.0, left: 6.0, bottom: 0.0, right: 0.0), borderInset: UIScreenPixel, strokeInsets: UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0)) let text = ChatMessageItemTextLayoutConstants(bubbleInsets: UIEdgeInsets(top: 6.0 + UIScreenPixel, left: 11.0, bottom: 6.0 - UIScreenPixel, right: 11.0)) - let image = ChatMessageItemImageLayoutConstants(bubbleInsets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0), statusInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 6.0, right: 6.0), defaultCornerRadius: 16.0, mergedCornerRadius: 8.0, contentMergedCornerRadius: 0.0, maxDimensions: CGSize(width: 300.0, height: 380.0), minDimensions: CGSize(width: 170.0, height: 74.0)) + let image = ChatMessageItemImageLayoutConstants(bubbleInsets: UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0), statusInsets: UIEdgeInsets(top: 0.0, left: 0.0, bottom: 6.0, right: 6.0), defaultCornerRadius: 15.0, mergedCornerRadius: 7.0, contentMergedCornerRadius: 0.0, maxDimensions: CGSize(width: 300.0, height: 380.0), minDimensions: CGSize(width: 170.0, height: 74.0)) let video = ChatMessageItemVideoLayoutConstants(maxHorizontalHeight: 250.0, maxVerticalHeight: 360.0) let file = ChatMessageItemFileLayoutConstants(bubbleInsets: UIEdgeInsets(top: 15.0, left: 9.0, bottom: 15.0, right: 12.0)) let instantVideo = ChatMessageItemInstantVideoConstants(insets: UIEdgeInsets(top: 4.0, left: 0.0, bottom: 4.0, right: 0.0), dimensions: CGSize(width: 212.0, height: 212.0)) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 44c77786f8..5ab0030bb4 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -26,8 +26,8 @@ public func chatMessageItemLayoutConstants(_ constants: (ChatMessageItemLayoutCo } else { result = constants.0 } - result.image.defaultCornerRadius = presentationData.chatBubbleCorners.mainRadius - result.image.mergedCornerRadius = (presentationData.chatBubbleCorners.mergeBubbleCorners && result.image.defaultCornerRadius >= 10.0) ? presentationData.chatBubbleCorners.auxiliaryRadius : presentationData.chatBubbleCorners.mainRadius + result.image.defaultCornerRadius = max(0.0, presentationData.chatBubbleCorners.mainRadius - 1.0) + result.image.mergedCornerRadius = max(0.0, ((presentationData.chatBubbleCorners.mergeBubbleCorners && result.image.defaultCornerRadius >= 10.0) ? presentationData.chatBubbleCorners.auxiliaryRadius : presentationData.chatBubbleCorners.mainRadius) - 1.0) let minRadius: CGFloat = 4.0 let maxRadius: CGFloat = 16.0 let radiusTransition = (presentationData.chatBubbleCorners.mainRadius - minRadius) / (maxRadius - minRadius) From 999aac6eb3a86fea6250b0c8f5851fb745c3f1e7 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:50:49 +0200 Subject: [PATCH 61/69] ListView: cap pin-to-edge item visible portion at half area When a pinToEdgeWithInset item is taller than half of the visible area (visibleSize.height - insets.top - insets.bottom), cap its visible portion at halfArea. The remaining height extends past visibleSize - insets.bottom into the bottom-inset region (occluded by overlay UI like the chat input panel). A new private helper `pinToEdgeBottomExtension(forPinnedHeight:)` returns max(0, pinnedHeight - halfArea). Three sites consume it: - calculatePinToEdgeTopInset caps the pinned item's contribution to totalAboveAndPinned via `pinnedHeight - extension`. - replayOperations isPinToEdgeTarget anchors the pinned item's apparent maxY at visibleSize - insets.bottom + extension. - isStrictlyScrolledToPinToEdgeItem matches the new anchor. Both isPinToEdgeTarget and isStrictlyScrolledToPinToEdgeItem fire when either calculatePinToEdgeTopInset() > 0 OR extension > 0, so the cap also applies when items above the pinned item overflow the visible area (in which case calculatePinToEdgeTopInset returns 0 but extension is still positive). When the pinned item fits within halfArea, extension == 0 and behavior is identical to before this change. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-01-listview-pin-to-edge-half-cap.md | 262 ------------------ ...01-listview-pin-to-edge-half-cap-design.md | 123 -------- submodules/Display/Source/ListView.swift | 49 ++-- 3 files changed, 32 insertions(+), 402 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md delete mode 100644 docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md diff --git a/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md b/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md deleted file mode 100644 index f0ae017a71..0000000000 --- a/docs/superpowers/plans/2026-05-01-listview-pin-to-edge-half-cap.md +++ /dev/null @@ -1,262 +0,0 @@ -# ListView pin-to-edge half-area cap — implementation plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Cap the visible portion of a `pinToEdgeWithInset` item in `ListViewImpl` at half the visible area, so a tall pinned item can't take over the whole screen. - -**Architecture:** Add a single private helper on `ListViewImpl` that returns the *bottom extension* (`max(0, pinnedHeight − halfArea)`). Three existing sites in `ListView.swift` consume the extension to (1) cap the pinned-item contribution to `pinToEdgeTopInset`, (2) lower the pin-to-edge-target scroll anchor, and (3) match the new anchor in `isStrictlyScrolledToPinToEdgeItem`. When `extension == 0`, all three sites compute exactly what they compute today. - -**Tech Stack:** Swift, Bazel build via `Make.py`. No tests in this project (per CLAUDE.md) — verification is full build + manual smoke. - -**Spec:** `docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md` - ---- - -## File Structure - -All edits in one file: `submodules/Display/Source/ListView.swift`. - -| Site | Lines (current) | Change | -|------|-----------------|--------| -| Helper (new) | inserted right after `calculatePinToEdgeTopInset` | new private method `pinToEdgeBottomExtension(forPinnedHeight:)` | -| `calculatePinToEdgeTopInset` | 1094–1119 | cap the pinned item's contribution to `totalAboveAndPinned` at `halfArea` | -| `isStrictlyScrolledToPinToEdgeItem` | 2683 | use `extension` to compute `expectedMaxY` | -| pin-to-edge-target offset | 3127 | use `extension` to compute the target offset | - -All four edits must land in a single commit — committing one without the others leaves the inset calculation and the actual anchor inconsistent. - ---- - -### Task 1: Apply the four edits to `ListView.swift` - -**Files:** -- Modify: `submodules/Display/Source/ListView.swift` (lines 1094–1119, 2683, 3127, plus new helper) - -- [ ] **Step 1: Update `calculatePinToEdgeTopInset()` to cap the pinned item's contribution** - -Find the existing function (currently lines 1094–1119): - -```swift - private func calculatePinToEdgeTopInset() -> CGFloat { - var lowestPinnedIndex: Int = Int.max - for itemNode in self.itemNodes { - guard let index = itemNode.index, index >= 0, index < self.items.count else { continue } - if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { - lowestPinnedIndex = index - } - } - guard lowestPinnedIndex != Int.max else { return 0.0 } - - var totalAboveAndPinned: CGFloat = 0.0 - var sawIndexZero = false - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { - sawIndexZero = true - } - if index <= lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } - } - guard sawIndexZero else { return 0.0 } - - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - return max(0.0, visibleArea - totalAboveAndPinned) - } -``` - -Replace with: - -```swift - private func calculatePinToEdgeTopInset() -> CGFloat { - var lowestPinnedIndex: Int = Int.max - for itemNode in self.itemNodes { - guard let index = itemNode.index, index >= 0, index < self.items.count else { continue } - if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { - lowestPinnedIndex = index - } - } - guard lowestPinnedIndex != Int.max else { return 0.0 } - - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - let halfArea = visibleArea * 0.5 - - var totalAboveAndPinned: CGFloat = 0.0 - var sawIndexZero = false - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { - sawIndexZero = true - } - if index < lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } else if index == lowestPinnedIndex { - let pinnedHeight = itemNode.apparentBounds.height - let effectivePinnedHeight = halfArea > 0.0 ? min(pinnedHeight, halfArea) : pinnedHeight - totalAboveAndPinned += effectivePinnedHeight - } - } - guard sawIndexZero else { return 0.0 } - - return max(0.0, visibleArea - totalAboveAndPinned) - } - - private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat { - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - let halfArea = visibleArea * 0.5 - guard halfArea > 0.0 else { return 0.0 } - return max(0.0, pinnedHeight - halfArea) - } -``` - -Key changes: -- `let visibleArea` and `let halfArea` moved up so they're available inside the loop. -- The `if index <= lowestPinnedIndex` branch is split into `< lowestPinnedIndex` (full height) and `== lowestPinnedIndex` (capped height). -- New helper `pinToEdgeBottomExtension(forPinnedHeight:)` added immediately after. - -- [ ] **Step 2: Update `isStrictlyScrolledToPinToEdgeItem()` to use the extension** - -Find (currently around line 2683): - -```swift - for itemNode in self.itemNodes { - if itemNode.index == targetIndex { - let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom - return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 - } - } -``` - -Replace with: - -```swift - for itemNode in self.itemNodes { - if itemNode.index == targetIndex { - let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) - let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom - return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 - } - } -``` - -- [ ] **Step 3: Update the pin-to-edge-target scroll offset in `replayOperations`** - -Find (currently around line 3127, inside `if isPinToEdgeTarget {`): - -```swift - var offset: CGFloat - if isPinToEdgeTarget { - offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom - } else { -``` - -Replace with: - -```swift - var offset: CGFloat - if isPinToEdgeTarget { - let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) - offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom - } else { -``` - -- [ ] **Step 4: Sanity-grep for any other use of the old anchor formula** - -Run from repo root: - -```bash -grep -nE "visibleSize\.height\s*-\s*insets?\.bottom\s*\)" submodules/Display/Source/ListView.swift -``` - -Inspect each hit. Sites listed in this plan (now patched) and `else` branches (`.bottom(additionalOffset)`, `.top(additionalOffset)`, `.center`, `.visible` at lines 3131, 3143, 3146, 3154, 3160) are scroll-position offsets for *non-pinned* items — they must NOT change. Confirm the only patched lines are the pin-to-edge-target paths. - -- [ ] **Step 5: Run the full Bazel build with `--continueOnError`** - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build completes successfully (no Swift errors). If the build surfaces errors in `submodules/Display/Source/ListView.swift`, re-read the patched sections against Step 1–3 above and fix. - -If unrelated errors appear in untouched files, they are pre-existing — note them and continue. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/Display/Source/ListView.swift -git commit -m "$(cat <<'EOF' -ListView: cap pin-to-edge item visible portion at half area - -When a pinToEdgeWithInset item is taller than half of the visible -area, its visible portion is now capped at halfArea. The remaining -height extends below visibleSize - insets.bottom into the -bottom-inset region, where it is occluded by overlay UI (input -panel, tab bar) in typical usage. - -Three sites updated to a single helper pinToEdgeBottomExtension: -calculatePinToEdgeTopInset (caps the pinned item's contribution), -the pin-to-edge-target scroll offset in replayOperations, and -isStrictlyScrolledToPinToEdgeItem. - -When the pinned item fits within halfArea, extension == 0 and all -three sites compute exactly what they did before. - -Spec: docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md -EOF -)" -``` - ---- - -### Task 2: Manual smoke test - -The change must be exercised on a real device/simulator because there are no unit tests for ListView in this project. - -**Test bench:** any chat that supports `pinToTop` messages (the only consumer of `pinToEdgeWithInset == true` per `ChatMessageItemImpl.swift:280`). - -- [ ] **Step 1: Short pinned message — unchanged behavior** - -Open a chat with a `pinToTop` message of ~1–3 lines (well under `halfArea`). Confirm: -- Pinned message is anchored at the top of the visible chat area (top of screen, given rotated chat). -- The list scrolls under it as expected. -- No visible difference from the pre-change build. - -- [ ] **Step 2: Mid-height pinned message — unchanged behavior** - -Open a chat with a `pinToTop` message that is sized just under `halfArea` (roughly 30–40% of the chat area). Confirm same as Step 1 — no behavior change. - -- [ ] **Step 3: Tall pinned message — capped behavior** - -Open a chat with a `pinToTop` message that is much taller than `halfArea` (e.g., a long text message that would naturally span more than half the visible chat area). Confirm: -- The pinned message occupies at most ~half of the visible chat area at the top of the screen. -- The remaining (lower) half of the chat area shows the regular thread content. -- The portion of the pinned message that "doesn't fit" is not visible — it should be occluded by the input panel / nav bar overlay. -- Scrolling behaves consistently: the pinned message stays anchored at the top edge, the rest of the thread scrolls underneath. - -- [ ] **Step 4: Inset / size changes — re-evaluation** - -While viewing a chat with a tall pinned message, open and close the keyboard (this changes `insets.bottom`). The cap should re-evaluate on each layout pass — no jumpy or stuck state. The pinned message's visible portion should remain ~half of the new visible area. - -If any of Steps 1–4 fails, do NOT call the work done. Re-open the spec, re-read the patched sites in `ListView.swift`, and identify which assumption broke. - ---- - -## Self-review - -**Spec coverage:** -- Helper `pinToEdgeBottomExtension(forPinnedHeight:)` → Task 1 Step 1. -- Cap in `calculatePinToEdgeTopInset` → Task 1 Step 1. -- Anchor change in pin-to-edge-target offset → Task 1 Step 3. -- Anchor change in `isStrictlyScrolledToPinToEdgeItem` → Task 1 Step 2. -- Edge cases (`pinnedHeight ≤ halfArea`, multiple pinned items, degenerate inset, dynamic resize, rotated chats) — covered by the helper's `max(0, …)` and the unchanged `lowestPinnedIndex` selection logic; smoke-tested in Task 2 Steps 1, 2, 4. -- Verification (full build, manual smoke) → Task 1 Step 5, Task 2. -- Risks (overflow into bottom-inset region) → Task 2 Step 3 explicitly checks occlusion. - -**Type/name consistency:** Helper signature `pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat` is identical at the declaration (Step 1) and both call sites (Steps 2, 3). Local variable name `extensionOffset` matches in Steps 2 and 3. - -**Placeholder scan:** No TBDs, no "implement appropriately", no missing code blocks. Each step's expected commands and outcomes are concrete. diff --git a/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md b/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md deleted file mode 100644 index 65ee2fcc5a..0000000000 --- a/docs/superpowers/specs/2026-05-01-listview-pin-to-edge-half-cap-design.md +++ /dev/null @@ -1,123 +0,0 @@ -# ListView pin-to-edge half-area cap — design - -## Problem - -In `ListViewImpl` (`submodules/Display/Source/ListView.swift`), an item that opts into `pinToEdgeWithInset` is anchored with its `apparentFrame.maxY` at `visibleSize.height − insets.bottom`. When the pinned item is taller than the available area, it dominates the entire visible region — its top extends above the top of the visible area, hiding adjacent content. - -The intended UX is that a pinned item never occupies more than half of the visible area, so context above (in list coords) / below (visually, in rotated chats) the pinned item remains visible. - -## Goal - -When the pinned item's height exceeds `halfArea = (visibleSize.height − insets.top − insets.bottom) / 2`, cap the pinned item's *visible portion* at `halfArea`. The remaining `pinnedHeight − halfArea` extends below the content area (between `visibleSize − insets.bottom` and `visibleSize`), into the bottom-inset region. The list view has `clipsToBounds = true` (line 498), so anything beyond `visibleSize.height` is clipped; in typical usage the bottom-inset region is occluded by overlay UI (chat input panel, tab bar) drawn on top of the list view. - -When the pinned item's height is `≤ halfArea`, behavior is identical to the current implementation. - -## Non-goals - -- No new public API on `ListView` or `ListViewItem`. The cap fraction is `0.5`, hard-coded. -- No changes to consumers (`ChatMessageItemImpl`, etc.). -- No changes to header/accessory layout. - -## Approach - -A single private helper computes the *bottom extension* — the distance by which the pinned item's bottom edge is pushed below `visibleSize − bottom`: - -``` -extension = max(0, pinnedHeight − halfArea) -``` - -When `extension > 0`, the pinned item's `apparentFrame.maxY` is anchored at `visibleSize − bottom + extension`, so its top edge sits at `visibleSize − bottom − halfArea`. The visible portion is exactly `halfArea`. - -Three existing sites in `ListView.swift` need to agree on the new anchor; all three use the same helper. - -### Helper - -Add to `ListViewImpl`: - -```swift -private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat { - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - let halfArea = visibleArea * 0.5 - guard halfArea > 0.0 else { return 0.0 } - return max(0.0, pinnedHeight - halfArea) -} -``` - -### Call site 1 — `calculatePinToEdgeTopInset()` (around line 1094) - -The pinned item's contribution to `totalAboveAndPinned` is capped at `halfArea`. This grows `pinToEdgeTopInset` when the pinned item is too tall, pushing items-above further down so their stack lines up with the new (lowered) pinned-item top edge. - -```swift -let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom -let halfArea = visibleArea * 0.5 - -var totalAboveAndPinned: CGFloat = 0.0 -var sawIndexZero = false -for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { sawIndexZero = true } - if index < lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } else if index == lowestPinnedIndex { - let pinnedHeight = itemNode.apparentBounds.height - let effectivePinnedHeight = halfArea > 0.0 ? min(pinnedHeight, halfArea) : pinnedHeight - totalAboveAndPinned += effectivePinnedHeight - } -} -guard sawIndexZero else { return 0.0 } -return max(0.0, visibleArea - totalAboveAndPinned) -``` - -### Call site 2 — pin-to-edge-target scroll offset (around line 3127) - -```swift -if isPinToEdgeTarget { - let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) - offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom -} -``` - -### Call site 3 — `isStrictlyScrolledToPinToEdgeItem()` (around line 2683) - -```swift -for itemNode in self.itemNodes { - if itemNode.index == targetIndex { - let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) - let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom - return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 - } -} -``` - -All three sites read `apparentBounds.height` for consistency with the rest of the file (which uses post-layout, post-animation heights for pinning math). - -## Edge cases - -- **`pinnedHeight ≤ halfArea`**: helper returns 0, all three sites compute identical values to the current implementation. No behavioural change in the common case. -- **Multiple items with `pinToEdgeWithInset == true`**: existing semantics select the smallest-indexed one as `lowestPinnedIndex`. The cap applies only to that item. Others sit above as normal items. -- **`visibleSize.height ≤ insets.top + insets.bottom`** (degenerate / unmeasured layout): `halfArea ≤ 0`, helper returns 0, no cap. -- **Inset / size changes**: `pinToEdgeTopInset` and the scroll offset are recomputed on every `snapToBounds` / `replayOperations`, so the cap re-evaluates whenever inputs change. -- **Rotated chats (the actual usage path)**: list-coord-bottom maps to top-of-screen; capping the pinned item's visible portion at `halfArea` in list coords keeps the start of the pinned message visible at the top of the screen with the rest extending above. - -## Verification - -No unit tests exist for ListView in this project (per CLAUDE.md). Verification is: - -1. **Full build** with `--continueOnError`: - ``` - source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError - ``` -2. **Manual smoke test** in a chat with a pinned message: - - Short pinned message (`< halfArea`): unchanged behavior — pinned message anchored at top of screen, list scrolls under it. - - Mid-height pinned message (just under `halfArea`): unchanged. - - Tall pinned message (much taller than `halfArea`): only ~half of the visible area shows the pinned message; the other half remains available for the chat thread. - -## Risks - -- The pinned item's bottom extends past `visibleSize.height − insets.bottom` when capped, into the bottom-inset region of the list view's bounds. In contexts where the bottom-inset region is *not* covered by overlay UI (e.g., a list with `insets.bottom = 0`, or a list with bottom inset reserved for spacing rather than for an overlay), the overflow tail of the pinned item is briefly visible above the bottom edge of the list view. For the primary consumer (chat with `pinToTop` messages), the input panel is drawn on top and occludes the region. Confirm during manual smoke test on non-chat consumers if any. -- The cap is half hard-coded. If a future feature wants per-item or per-list configuration, this becomes a public knob; for now keep it private. diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index e3e2dba497..a00cc9c112 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1101,6 +1101,8 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur } guard lowestPinnedIndex != Int.max else { return 0.0 } + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + var totalAboveAndPinned: CGFloat = 0.0 var sawIndexZero = false for itemNode in self.itemNodes { @@ -1108,16 +1110,25 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur if index == 0 { sawIndexZero = true } - if index <= lowestPinnedIndex { + if index < lowestPinnedIndex { totalAboveAndPinned += itemNode.apparentBounds.height + } else if index == lowestPinnedIndex { + let pinnedHeight = itemNode.apparentBounds.height + totalAboveAndPinned += pinnedHeight - self.pinToEdgeBottomExtension(forPinnedHeight: pinnedHeight) } } guard sawIndexZero else { return 0.0 } - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom return max(0.0, visibleArea - totalAboveAndPinned) } + private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat { + let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom + let halfArea = visibleArea * 0.5 + guard halfArea > 0.0 else { return 0.0 } + return max(0.0, pinnedHeight - halfArea) + } + private func areAllItemsOnScreen() -> Bool { if self.itemNodes.count == 0 { return true @@ -2672,15 +2683,17 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur } public func isStrictlyScrolledToPinToEdgeItem() -> Bool { - if self.calculatePinToEdgeTopInset() <= 0.0 { - return false - } guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else { return false } + let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() for itemNode in self.itemNodes { if itemNode.index == targetIndex { - let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + guard pinToEdgeTopInset > 0.0 || extensionOffset > 0.0 else { + return false + } + let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5 } } @@ -3108,23 +3121,25 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur let insets = self.insets// updateSizeAndInsets?.insets ?? self.insets var isPinToEdgeTarget = false - if self.calculatePinToEdgeTopInset() > 0.0, - index >= 0, index < self.items.count, - self.items[index].pinToEdgeWithInset { - isPinToEdgeTarget = true - for otherNode in self.itemNodes { - guard let otherIndex = otherNode.index else { continue } - guard otherIndex >= 0, otherIndex < self.items.count else { continue } - if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset { - isPinToEdgeTarget = false - break + if index >= 0, index < self.items.count, self.items[index].pinToEdgeWithInset { + let pinExtension = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + if self.calculatePinToEdgeTopInset() > 0.0 || pinExtension > 0.0 { + isPinToEdgeTarget = true + for otherNode in self.itemNodes { + guard let otherIndex = otherNode.index else { continue } + guard otherIndex >= 0, otherIndex < self.items.count else { continue } + if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset { + isPinToEdgeTarget = false + break + } } } } var offset: CGFloat if isPinToEdgeTarget { - offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom + let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) + offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom } else { switch scrollToItem.position { case let .bottom(additionalOffset): From d02019f985fa831e173d30c6dd8e1951da820919 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 12:57:13 +0200 Subject: [PATCH 62/69] Rich bubble: instant-page link handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ChatControllerInteraction dependency, link-progress state, URL tap detection, press-highlight separation from in-flight URL shimmer, media-tap routing through openMessage with explicit IV media subject, and gallery↔bubble transition with hidden-media coordination. Stops re-appending to currentLayoutItemsWithNodes across re-layouts. Drops a leftover MetalEngine debug print as well. --- ...-rich-bubble-instant-page-link-handling.md | 655 ++++++++++++++++++ .../Sources/GalleryController.swift | 1 + .../GalleryData/Sources/GalleryData.swift | 25 +- .../MetalEngine/Sources/MetalEngine.swift | 14 - .../BUILD | 2 + ...ChatMessageRichDataBubbleContentNode.swift | 372 ++++++++-- 6 files changed, 972 insertions(+), 97 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md diff --git a/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md b/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md new file mode 100644 index 0000000000..c1a523b5a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md @@ -0,0 +1,655 @@ +# Rich-bubble instant-page link handling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire URL tap routing and link-highlight feedback into `ChatMessageRichDataBubbleContentNode`, plus stubbed handlers for intra-page anchor scrolling. + +**Architecture:** All changes land in a single Swift file (`ChatMessageRichDataBubbleContentNode.swift`) plus its BUILD file. Tap detection mirrors `InstantPageControllerNode.textItemAtLocation` / `urlForTapLocation` scoped to the bubble's already-built `currentPageLayout`. URL hits return a standard `ChatMessageBubbleContentTapAction(.url(...), rects:, activate:)` which the bubble framework routes through `controllerInteraction.openUrl`. Highlight feedback uses a `LinkHighlightingNode` overlay inside `containerNode`, driven by the `activate` `Promise` exactly like the chat text bubble. Same-page-anchor URLs short-circuit into a no-op `scrollToAnchor(_:)` placeholder for later implementation. + +**Tech Stack:** Swift, AsyncDisplayKit, Bazel (`build-system/Make/Make.py`), modules `Display`, `InstantPageUI`, `ChatControllerInteraction`, `ChatMessageBubbleContentNode`, `TelegramCore`, `SwiftSignalKit`. + +**Reference spec:** `docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md` + +**Project context:** No automated tests exist (per `CLAUDE.md`). Per-task verification is "build green" using: + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ + --continueOnError +``` + +Final manual smoke test runs the app and exercises the feature in the simulator. + +--- + +## File map + +- **Modified:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — adds tap detection helpers, link-highlight state and overlay management, real `openPeer`/`openUrl` item callbacks, anchor stub, and a populated `tapActionAtPoint`. +- **Modified:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — adds `//submodules/TelegramUI/Components/ChatControllerInteraction` to `deps`. + +No other files change. + +--- + +### Task 1: Add `ChatControllerInteraction` BUILD dep + import + +The rich-bubble currently does not have access to `ChatControllerInteraction` as an importable module. Sibling modules (e.g. `ChatMessageWebpageBubbleContentNode`) list it explicitly in BUILD deps and import it. We follow the same pattern. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift:1-12` + +- [ ] **Step 1: Add the dep to BUILD** + +In `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD`, add the new dep line at the end of the existing `deps` list (alphabetical order is not enforced in this repo's BUILD files; place it after `ChatMessageItemCommon`): + +Replace: +``` + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/InstantPageUI", + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUIPreferences", + ], +``` + +With: +``` + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/TelegramCore", + "//submodules/Postbox", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/AccountContext", + "//submodules/InstantPageUI", + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUIPreferences", + ], +``` + +- [ ] **Step 2: Add the import** + +At the top of `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`, after `import ChatMessageItemCommon` add `import ChatControllerInteraction`: + +Replace: +```swift +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import InstantPageUI +import TelegramUIPreferences +``` + +With: +```swift +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import ChatControllerInteraction +import InstantPageUI +import TelegramUIPreferences +``` + +- [ ] **Step 3: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. The new import is unused; Swift does not warn on unused module imports, so `-warnings-as-errors` is fine. + +- [ ] **Step 4: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: add ChatControllerInteraction dep and import" +``` + +--- + +### Task 2: Add link-progress state, deinit cleanup, and helper stubs + +Before introducing tap-detection or item-callback logic, add the supporting plumbing: progress state, the empty `scrollToAnchor` placeholder, the URL-anchor splitter, and a `currentLoadedWebpage()` accessor. These are private helpers that future tasks will consume — Swift does not warn on unused private functions, so the build stays green. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Add the new ivars** + +Locate the existing block of stored properties around lines 18–25: + +```swift + private let containerNode: ContainerNode + private var currentLayoutTiles: [InstantPageTile] = [] + private var visibleTiles: [Int: InstantPageTileNode] = [:] + private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] + private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? + private var distanceThresholdGroupCount: [Int: Int] = [:] + private var currentLayoutItemsWithNodes: [InstantPageItem] = [] + private var currentExpandedDetails: [Int : Bool]? +``` + +Append the three highlight-related fields immediately after `currentExpandedDetails`: + +```swift + private let containerNode: ContainerNode + private var currentLayoutTiles: [InstantPageTile] = [] + private var visibleTiles: [Int: InstantPageTileNode] = [:] + private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] + private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? + private var distanceThresholdGroupCount: [Int: Int] = [:] + private var currentLayoutItemsWithNodes: [InstantPageItem] = [] + private var currentExpandedDetails: [Int : Bool]? + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? +``` + +- [ ] **Step 2: Update `deinit` to dispose the link-progress signal** + +Locate the existing empty `deinit` (around line 48): + +```swift + deinit { + } +``` + +Replace with: + +```swift + deinit { + self.linkProgressDisposable?.dispose() + } +``` + +- [ ] **Step 3: Add helper methods at the end of the class** + +Locate the closing brace of the class (the last `}` in the file). Immediately before it, add the four helpers (anchor split, current-loaded-webpage accessor, anchor-scroll stub, and a small TODO note): + +```swift + private func splitAnchor(_ url: String) -> (base: String, anchor: String?) { + if let anchorRange = url.range(of: "#") { + let anchor = String(url[anchorRange.upperBound...]).removingPercentEncoding + let base = String(url[.. TelegramMediaWebpageLoadedContent? { + guard let item = self.item else { + return nil + } + guard let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage else { + return nil + } + if case let .Loaded(content) = webpage.content { + return content + } + return nil + } + + private func scrollToAnchor(_ anchor: String) { + // TODO: implement intra-page anchor scrolling + let _ = anchor + } +``` + +The `let _ = anchor` line silences any "unused parameter" lint while keeping the parameter name visible. (Required because `-warnings-as-errors` is enabled on this module.) + +- [ ] **Step 4: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. Private functions (even unused) and disposable ivars compile cleanly under `-warnings-as-errors`. + +- [ ] **Step 5: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: add link-progress state and anchor-scroll stub" +``` + +--- + +### Task 3: Wire item-level `openPeer` and `openUrl` callbacks + +Replace the stubbed item-callback closures inside the `item.node(...)` invocation. Item nodes themselves emit URL/peer taps via their callback parameters; we route these to `controllerInteraction.openUrl` / `controllerInteraction.openPeer`. `openMedia`, `longPressMedia`, `activatePinchPreview`, `pinchPreviewFinished`, `updateWebEmbedHeight`, and `updateDetailsExpanded` remain explicit no-ops (per spec — out of scope for this change). + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (the `item.node(...)` call inside `updateVisibleItems`, currently at lines ~233–278) + +- [ ] **Step 1: Replace the `item.node(...)` callback block** + +Locate the existing call (the entire block from `if let newNode = item.node(context: ...` through the matching `currentExpandedDetails: ..., getPreloadedResource: { _ in return nil }) {` line). Replace ONLY the trailing-closure callback parameters — keep `context:`, `strings:`, `nameDisplayOrder:`, `theme:`, `sourceLocation:`, `currentExpandedDetails:`, and `getPreloadedResource:` intact. + +Replace the existing block: + +```swift + if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { [weak self] media in + let _ = self + //self?.openMedia(media) + }, longPressMedia: { [weak self] media in + //self?.longPressMedia(media) + let _ = self + }, activatePinchPreview: { [weak self] sourceNode in + /*guard let strongSelf = self, let controller = strongSelf.controller else { + return + } + let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { + guard let strongSelf = self else { + return CGRect() + } + + let localRect = CGRect(origin: CGPoint(x: 0.0, y: strongSelf.navigationBar.frame.maxY), size: CGSize(width: strongSelf.bounds.width, height: strongSelf.bounds.height - strongSelf.navigationBar.frame.maxY)) + return strongSelf.view.convert(localRect, to: nil) + }) + controller.window?.presentInGlobalOverlay(pinchController)*/ + let _ = self + }, pinchPreviewFinished: { [weak self] itemNode in + /*guard let strongSelf = self else { + return + } + for (_, listItemNode) in strongSelf.visibleItemsWithNodes { + if let listItemNode = listItemNode as? InstantPagePeerReferenceNode { + if listItemNode.frame.intersects(itemNode.frame) && listItemNode.frame.maxY <= itemNode.frame.maxY + 2.0 { + listItemNode.layer.animateAlpha(from: 0.0, to: listItemNode.alpha, duration: 0.25) + break + } + } + }*/ + let _ = self + }, openPeer: { [weak self] peerId in + let _ = self + //self?.openPeer(peerId) + }, openUrl: { [weak self] url in + let _ = self + //self?.openUrl(url) + }, updateWebEmbedHeight: { [weak self] height in + let _ = self + //self?.updateWebEmbedHeight(embedIndex, height) + }, updateDetailsExpanded: { [weak self] expanded in + let _ = self + //self?.updateDetailsExpanded(detailsIndex, expanded) + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { +``` + +With: + +```swift + if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { _ in + // TODO: media handling — out of scope for link wiring + }, longPressMedia: { _ in + // TODO + }, activatePinchPreview: { _ in + // TODO + }, pinchPreviewFinished: { _ in + // TODO + }, openPeer: { [weak self] peer in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) + }, openUrl: { [weak self] urlItem in + guard let self, let item = self.item else { + return + } + let split = self.splitAnchor(urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { + self.scrollToAnchor(anchor) + return + } + item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( + url: urlItem.url, + concealed: false, + message: item.message, + allowInlineWebpageResolution: urlItem.webpageId != nil + )) + }, updateWebEmbedHeight: { _ in + // TODO + }, updateDetailsExpanded: { _ in + // TODO + }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { +``` + +Notes on this block: +- `openPeer` parameter is `(EnginePeer) -> Void` (verified against `InstantPageItem.swift:18`) — name it `peer`, not `peerId`. +- `openUrl` parameter is `(InstantPageUrlItem) -> Void` — name it `urlItem` to disambiguate from the outer URL string. +- The same-page-anchor short-circuit calls the empty `scrollToAnchor` stub so future implementation is single-point. +- `urlItem.webpageId != nil` is mapped to `allowInlineWebpageResolution` because the IV's `webpageId` hint signals "this URL was authored as a referenced webpage" — the same intent as the chat flag. +- `concealed: false` for item-emitted URLs (item nodes only emit clearly-typed link items, not free-form anchor-text mismatches). The text-tap path in Task 4 uses `concealed: true` per spec. + +- [ ] **Step 2: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. If a closure-parameter type mismatch is reported, re-check `InstantPageItem.swift:18` for the canonical signature. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: route item-level openPeer/openUrl to controllerInteraction" +``` + +--- + +### Task 4: Implement URL tap detection, highlight feedback, and `tapActionAtPoint` + +Add the tap-detection helpers, the rect-translation helper, the `makeActivate` factory, and the `updateLinkProgressState` view-state applier. Then rewrite `tapActionAtPoint` to use them. This is the largest task; it must land atomically because the helpers and the rewritten `tapActionAtPoint` reference each other. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Replace `tapActionAtPoint` and add tap-detection helpers** + +Locate the existing `tapActionAtPoint` (around lines 403–442 with its commented-out `makeActivate` block) and replace the entire method. Then add the new helpers immediately afterward (so they live alongside the related logic). + +Replace the existing method body: + +```swift + override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if case .tap = gesture { + } else { + if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { + return ChatMessageBubbleContentTapAction(content: .none) + } + } + + /*func makeActivate(_ urlRange: NSRange?) -> (() -> Promise?)? { + return { [weak self] in + guard let self else { + return nil + } + + let promise = Promise() + + self.linkProgressDisposable?.dispose() + + if self.linkProgressRange != nil { + self.linkProgressRange = nil + self.updateLinkProgressState() + } + + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + let updatedRange: NSRange? = value ? urlRange : nil + if self.linkProgressRange != updatedRange { + self.linkProgressRange = updatedRange + self.updateLinkProgressState() + } + }) + + return promise + } + }*/ + + return ChatMessageBubbleContentTapAction(content: .none) + } +``` + +With: + +```swift + override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if case .tap = gesture { + } else { + if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { + return ChatMessageBubbleContentTapAction(content: .none) + } + } + + guard let urlHit = self.urlForTapLocation(point) else { + return ChatMessageBubbleContentTapAction(content: .none) + } + + let split = self.splitAnchor(urlHit.urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { + return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in + self?.scrollToAnchor(anchor) + })) + } + + // Default to concealed=true: InstantPageTextItem does not expose a clean + // "attribute substring with displayed range" API, so we cannot compare + // displayed text to the resolved URL the way the chat text bubble does. + // The chat URL handler will show a confirmation when concealed is true + // and the visible text differs from the destination — safer default. + let concealed = true + let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) + let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + return ChatMessageBubbleContentTapAction( + content: .url(url), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } + + private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? { + guard let layout = self.currentPageLayout?.layout else { + return nil + } + // Translate from bubble-content-node coords to container-/layout-local coords. + let layoutLocation = location.offsetBy(dx: -1.0, dy: -1.0) + for item in layout.items { + let itemFrame = item.frame + if itemFrame.contains(layoutLocation) { + if let item = item as? InstantPageTextItem, item.selectable { + return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY)) + } else if let item = item as? InstantPageScrollableItem { + let contentOffset = CGPoint.zero + if let (textItem, parentOffset) = item.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) { + return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y)) + } + } else if let item = item as? InstantPageDetailsItem { + for (_, itemNode) in self.visibleItemsWithNodes { + if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item { + if let (textItem, parentOffset) = itemNode.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) { + return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y)) + } + } + } + } + } + } + return nil + } + + private func urlForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, parentOffset: CGPoint, localPoint: CGPoint)? { + guard let (item, parentOffset) = self.textItemAtLocation(point) else { + return nil + } + // Translate bubble-content-node point → text-item-local point. + // (bubble-coords → layout-coords) is `- (1, 1)`; (layout → item-local) is `- item.frame.origin - parentOffset`. + let layoutPoint = point.offsetBy(dx: -1.0, dy: -1.0) + let localPoint = layoutPoint.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y) + guard let urlItem = item.urlAttribute(at: localPoint) else { + return nil + } + return (item, urlItem, parentOffset, localPoint) + } + + private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] { + // Text item returns rects in its local coords; translate back into containerNode-local coords. + // containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives + // *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects. + let originX = item.frame.minX + parentOffset.x + let originY = item.frame.minY + parentOffset.y + return item.linkSelectionRects(at: localPoint).map { rect in + rect.offsetBy(dx: originX, dy: originY) + } + } + + private func makeActivate(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> (() -> Promise?)? { + return { [weak self, weak item] in + guard let self else { + return nil + } + let promise = Promise() + self.linkProgressDisposable?.dispose() + if self.linkProgressRects != nil { + self.linkProgressRects = nil + self.updateLinkProgressState() + } + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + let updated: [CGRect]? + if value, let item { + updated = self.computeHighlightRects(item: item, parentOffset: parentOffset, localPoint: localPoint) + } else { + updated = nil + } + let changed: Bool + if let lhs = self.linkProgressRects, let rhs = updated { + changed = lhs != rhs + } else { + changed = (self.linkProgressRects == nil) != (updated == nil) + } + if changed { + self.linkProgressRects = updated + self.updateLinkProgressState() + } + }) + return promise + } + } + + private func updateLinkProgressState() { + guard let messageItem = self.item else { + return + } + if let rects = self.linkProgressRects, !rects.isEmpty { + let highlightingNode: LinkHighlightingNode + if let current = self.linkHighlightingNode { + highlightingNode = current + } else { + let color: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + ? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor + : messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor + highlightingNode = LinkHighlightingNode(color: color) + self.linkHighlightingNode = highlightingNode + self.containerNode.insertSubnode(highlightingNode, at: 0) + } + highlightingNode.frame = self.containerNode.bounds + highlightingNode.updateRects(rects) + } else if let highlightingNode = self.linkHighlightingNode { + self.linkHighlightingNode = nil + highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in + highlightingNode?.removeFromSupernode() + }) + } + } +``` + +Notes: +- Coordinate translation: incoming `point` is bubble-content-node-local. The bubble's `containerNode.frame.origin` is `(1, 1)` (set in the `apply` closure around line 96). Subtracting `(1, 1)` once gives container-local = layout-local coordinates. The layout origin is `(0, 0)` inside the container. +- `InstantPageScrollableItem`'s realized node would be the source of truth for content-offset, but the rich-bubble does not surface scroll state and chat instant-pages rarely contain scrollable items. Pass `CGPoint.zero` for v1; if a chat preview ever uses a scrollable, the tap detection will be slightly off but URL-hit will still resolve when the scroll is at top. (Out of scope for this change; document as a follow-up if it becomes user-visible.) +- `[weak item]` in the activate closure avoids retaining the InstantPageTextItem across asynchronous URL resolution. If layout reflows during resolution and the original item is gone, the highlight simply falls back to clearing. +- The `changed` comparison for `[CGRect]?` is spelled out longhand because optional `Equatable` conformance for `[CGRect]?` requires the explicit nil-vs-non-nil discrimination to satisfy `-warnings-as-errors` cleanly. + +- [ ] **Step 2: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. + +Common likely errors and fixes: +- "Cannot find type `InstantPageTextItem` / `InstantPageScrollableItem` / `InstantPageDetailsItem` / `InstantPageDetailsNode` / `InstantPageUrlItem`" → all are public in `InstantPageUI`, already imported; rebuild without `--continueOnError` and re-check the exact error. +- "Cannot find `LinkHighlightingNode`" → it lives in `Display`, already imported. +- "`Promise` is ambiguous" → `Promise` is from `SwiftSignalKit`, already imported. +- A `linkHighlightColor` access that errors with optional unwrap → the type is non-optional `UIColor` on `MessageBubbleColorComponents`; this should compile cleanly. If the compiler reports a different type, drop back to `?? UIColor.clear` and document. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: implement URL tap detection and link-highlight feedback" +``` + +--- + +### Task 5: Manual smoke verification + +There are no automated tests for chat UI in this codebase. The final task is a hands-on smoke test in the simulator. + +**Files:** none modified. Verification only. + +- [ ] **Step 1: Launch the app in the simulator** + +The build command run in earlier tasks produces the app binary; launch via Xcode (open `Telegram-iOS.xcworkspace` if needed) or the Bazel-produced run target. Sign in to a real test account. + +- [ ] **Step 2: Find or send a message with an instant-view preview** + +In a chat, send a Telegram URL that has a rich instant-view preview (e.g. a Telegraph article URL: `https://telegra.ph/Test-page-12-31`, or any t.me link that produces an inline IV preview). The preview should render inside the chat bubble using the rich-data layout (multiple text/image tiles inside one bubble). + +- [ ] **Step 3: Tap a URL inside the inline IV preview** + +A standard URL link inside the preview text: +- Should highlight (semi-transparent rounded rectangle in the bubble's link-highlight color) for the duration of URL resolution. +- Should then route through the chat's URL handler — opening an in-app webview, an external browser, or a peer/chat depending on the URL. + +- [ ] **Step 4: Tap a same-page anchor (if available)** + +Some IV pages contain intra-page anchors like `#section-1`. Tapping such a link should fire the empty `scrollToAnchor` stub — observable as: no navigation, no error, no highlight, no external browser. (The TODO is logged for future work.) + +- [ ] **Step 5: Long-press a URL** + +A long-press on a URL inside the inline preview should trigger the chat's default URL long-press menu (Open / Copy / Share via the standard `controllerInteraction.longTap` path provided by the bubble framework for `.url` taps). The bubble's own custom long-press action sheet is out of scope for this change. + +- [ ] **Step 6: Sign-off** + +If steps 3–5 behave as described, the change is complete. If a step fails, capture: the URL used, observed behavior, and any console output. Common deviations: +- Highlight not appearing → confirm `containerNode` insertion order; the highlight node sits at index 0 below tiles, and tile background must be `.clear` (verify in `InstantPageTileNode`). +- URL resolves to nothing → confirm the upstream chat is correctly forwarding `controllerInteraction.openUrl` (this is the same wiring used by every other chat bubble). +- Anchor tap routes externally → confirm `currentLoadedWebpage()?.url == split.base` is matching; instant-page URLs sometimes carry trailing slashes in source vs. anchor links. + +--- + +## Self-review + +**Spec coverage:** +- Spec §"New private state" → Task 2 Step 1. +- Spec §"deinit disposes" → Task 2 Step 2. +- Spec §"Tap detection" → Task 4 Step 1 (textItemAtLocation, urlForTapLocation). +- Spec §"`tapActionAtPoint` body" → Task 4 Step 1 (full rewrite). +- Spec §"Concealed flag" default `true` → Task 4 Step 1 inline comment. +- Spec §"Highlight feedback" (`makeActivate`, `computeHighlightRects`, `updateLinkProgressState`) → Task 4 Step 1. +- Spec §"Item-callback wiring" → Task 3 Step 1. +- Spec §"Helpers" (`splitAnchor`, `currentLoadedWebpage`, `scrollToAnchor`) → Task 2 Step 3. +- Spec §"Verification" → Task 5 (manual smoke). +- Spec §"Out of scope" stays out of scope; no tasks added. + +All sections covered. + +**Placeholder scan:** TODO markers exist *only* inside the generated stub callbacks (intentional, per spec) and in the body of `scrollToAnchor` (intentional placeholder). No "TBD"/"fill in details"/"similar to Task N" present. + +**Type consistency:** +- `InstantPageTextItem`, `InstantPageUrlItem`, `InstantPageScrollableItem`, `InstantPageDetailsItem`, `InstantPageDetailsNode` — all referenced consistently across tasks. +- `EnginePeer` — implicit via `openPeer` callback signature (verified against `InstantPageItem.swift:18`). +- `ChatControllerInteraction.OpenUrl` initializer parameters (`url:concealed:external:message:allowInlineWebpageResolution:progress:`) — verified against `ChatControllerInteraction.swift:151`. +- `LinkHighlightingNode(color:)` and `updateRects(_:color:)` — verified against `Display/Source/LinkHighlightingNode.swift:334,346`. +- `splitAnchor` returns `(base: String, anchor: String?)`; consumers in Task 3 Step 1 and Task 4 Step 1 destructure with `let split = self.splitAnchor(...)` then access `split.base` / `split.anchor` — consistent. +- `currentLoadedWebpage()` returns `TelegramMediaWebpageLoadedContent?`; consumers use `webpage.url` which is a property of that type — consistent with the existing usage pattern at line 67 of the file. +- `urlForTapLocation` return tuple labels: `(item, urlItem, parentOffset, localPoint)`. Consumed by `tapActionAtPoint` via `urlHit.item`, `urlHit.urlItem`, `urlHit.parentOffset`, `urlHit.localPoint` — consistent. +- `computeHighlightRects` and `makeActivate` both take `(item:parentOffset:localPoint:)` — consistent. diff --git a/submodules/AccountContext/Sources/GalleryController.swift b/submodules/AccountContext/Sources/GalleryController.swift index a18a697c26..7a13847f0c 100644 --- a/submodules/AccountContext/Sources/GalleryController.swift +++ b/submodules/AccountContext/Sources/GalleryController.swift @@ -10,6 +10,7 @@ public enum GalleryMediaSubject: Hashable { case pollDescription case pollOption(Data) case pollSolution + case instantPageMedia(MediaId) } public enum GalleryControllerItemSource { diff --git a/submodules/GalleryData/Sources/GalleryData.swift b/submodules/GalleryData/Sources/GalleryData.swift index 91ed759a29..c694af95f8 100644 --- a/submodules/GalleryData/Sources/GalleryData.swift +++ b/submodules/GalleryData/Sources/GalleryData.swift @@ -174,15 +174,26 @@ public func chatMessageGalleryControllerData( } } - if let instantPage = content.instantPage, let galleryMedia = galleryMedia { - switch instantPageType(of: content) { - case .album: - let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) - if medias.count > 1 { + if let instantPage = content.instantPage { + if case let .instantPageMedia(tappedMediaId) = mediaSubject { + let parsedPage = instantPage._parse() + if let tappedMedia = parsedPage.media[tappedMediaId] { + let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: tappedMedia) + if !medias.isEmpty { instantPageMedia = (webpage, medias) + galleryMedia = tappedMedia } - default: - break + } + } else if let galleryMedia = galleryMedia { + switch instantPageType(of: content) { + case .album: + let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) + if medias.count > 1 { + instantPageMedia = (webpage, medias) + } + default: + break + } } } } else if let mapMedia = media as? TelegramMediaMap { diff --git a/submodules/MetalEngine/Sources/MetalEngine.swift b/submodules/MetalEngine/Sources/MetalEngine.swift index 7fc7064245..775a108a97 100644 --- a/submodules/MetalEngine/Sources/MetalEngine.swift +++ b/submodules/MetalEngine/Sources/MetalEngine.swift @@ -1069,20 +1069,6 @@ public final class MetalEngine { self.surfaces.removeValue(forKey: id) } - #if DEBUG - #if targetEnvironment(simulator) - if #available(iOS 13.0, *) { - if let drawable = self.layer.nextDrawable() { - commandBuffer.present(drawable) - } - } - #else - if let drawable = self.layer.nextDrawable() { - commandBuffer.present(drawable) - } - #endif - #endif - commandBuffer.commit() commandBuffer.waitUntilScheduled() } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index 7c9d63aaa9..28bdeb5f94 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -19,6 +19,8 @@ swift_library( "//submodules/InstantPageUI", "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/TelegramUIPreferences", ], visibility = [ diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index dbb3525d44..dc033a5580 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -8,8 +8,10 @@ import SwiftSignalKit import AccountContext import ChatMessageBubbleContentNode import ChatMessageItemCommon +import ChatControllerInteraction import InstantPageUI import TelegramUIPreferences +import TextLoadingEffect public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode { public final class ContainerNode: ASDisplayNode { @@ -23,6 +25,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var distanceThresholdGroupCount: [Int: Int] = [:] private var currentLayoutItemsWithNodes: [InstantPageItem] = [] private var currentExpandedDetails: [Int : Bool]? + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? + private var linkProgressView: TextLoadingEffectView? override public var visibility: ListViewItemNodeVisibility { didSet { @@ -46,6 +52,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } deinit { + self.linkProgressDisposable?.dispose() } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { @@ -72,6 +79,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode let pageTheme = instantPageThemeForType(item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( themeType: item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, fontSize: .standard, + lineSpacingFactor: 0.9, forceSerif: false, autoNightMode: false, ignoreAutoNightModeUntil: 0 @@ -98,13 +106,14 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode if let pageLayout { self.currentPageLayout = (boundingSize.width, pageLayout) self.currentLayoutTiles = currentLayoutTiles - + + var currentLayoutItemsWithNodes: [InstantPageItem] = [] var distanceThresholdGroupCount: [Int : Int] = [:] - + for item in pageLayout.items { if item.wantsNode { - self.currentLayoutItemsWithNodes.append(item) - + currentLayoutItemsWithNodes.append(item) + if let group = item.distanceThresholdGroup() { let count: Int if let currentCount = distanceThresholdGroupCount[Int(group)] { @@ -116,11 +125,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } } } - + + self.currentLayoutItemsWithNodes = currentLayoutItemsWithNodes self.distanceThresholdGroupCount = distanceThresholdGroupCount } else { self.currentPageLayout = nil self.currentLayoutTiles = [] + self.currentLayoutItemsWithNodes = [] self.distanceThresholdGroupCount = [:] } @@ -153,6 +164,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode let pageTheme = instantPageThemeForType(messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( themeType: messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, fontSize: .standard, + lineSpacingFactor: 0.9, forceSerif: false, autoNightMode: false, ignoreAutoNightModeUntil: 0 @@ -231,50 +243,40 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode //let embedIndex = embedIndex //let detailsIndex = detailsIndex if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { [weak self] media in - let _ = self - //self?.openMedia(media) - }, longPressMedia: { [weak self] media in - //self?.longPressMedia(media) - let _ = self - }, activatePinchPreview: { [weak self] sourceNode in - /*guard let strongSelf = self, let controller = strongSelf.controller else { + guard let self, let item = self.item, let mediaId = media.media.id else { return } - let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { - guard let strongSelf = self else { - return CGRect() - } - - let localRect = CGRect(origin: CGPoint(x: 0.0, y: strongSelf.navigationBar.frame.maxY), size: CGSize(width: strongSelf.bounds.width, height: strongSelf.bounds.height - strongSelf.navigationBar.frame.maxY)) - return strongSelf.view.convert(localRect, to: nil) - }) - controller.window?.presentInGlobalOverlay(pinchController)*/ - let _ = self - }, pinchPreviewFinished: { [weak self] itemNode in - /*guard let strongSelf = self else { + let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: .default, mediaSubject: .instantPageMedia(mediaId))) + }, longPressMedia: { _ in + // TODO + }, activatePinchPreview: { _ in + // TODO + }, pinchPreviewFinished: { _ in + // TODO + }, openPeer: { [weak self] peer in + guard let self, let item = self.item else { return } - for (_, listItemNode) in strongSelf.visibleItemsWithNodes { - if let listItemNode = listItemNode as? InstantPagePeerReferenceNode { - if listItemNode.frame.intersects(itemNode.frame) && listItemNode.frame.maxY <= itemNode.frame.maxY + 2.0 { - listItemNode.layer.animateAlpha(from: 0.0, to: listItemNode.alpha, duration: 0.25) - break - } - } - }*/ - let _ = self - }, openPeer: { [weak self] peerId in - let _ = self - //self?.openPeer(peerId) - }, openUrl: { [weak self] url in - let _ = self - //self?.openUrl(url) - }, updateWebEmbedHeight: { [weak self] height in - let _ = self - //self?.updateWebEmbedHeight(embedIndex, height) - }, updateDetailsExpanded: { [weak self] expanded in - let _ = self - //self?.updateDetailsExpanded(detailsIndex, expanded) + item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) + }, openUrl: { [weak self] urlItem in + guard let self, let item = self.item else { + return + } + let split = self.splitAnchor(urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { + self.scrollToAnchor(anchor) + return + } + item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( + url: urlItem.url, + concealed: false, + message: item.message, + allowInlineWebpageResolution: urlItem.webpageId != nil + )) + }, updateWebEmbedHeight: { _ in + // TODO + }, updateDetailsExpanded: { _ in + // TODO }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { newNode.frame = itemFrame newNode.updateLayout(size: itemFrame.size, transition: transition) @@ -407,45 +409,186 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return ChatMessageBubbleContentTapAction(content: .none) } } - - /*func makeActivate(_ urlRange: NSRange?) -> (() -> Promise?)? { - return { [weak self] in - guard let self else { - return nil + + guard let urlHit = self.urlForTapLocation(point) else { + return ChatMessageBubbleContentTapAction(content: .none) + } + + let split = self.splitAnchor(urlHit.urlItem.url) + if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { + return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in + self?.scrollToAnchor(anchor) + })) + } + + // Default to concealed=true: InstantPageTextItem does not expose a clean + // "attribute substring with displayed range" API, so we cannot compare + // displayed text to the resolved URL the way the chat text bubble does. + // The chat URL handler will show a confirmation when concealed is true + // and the visible text differs from the destination — safer default. + let concealed = true + let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) + let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + return ChatMessageBubbleContentTapAction( + content: .url(url), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } + + private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? { + guard let layout = self.currentPageLayout?.layout else { + return nil + } + // Translate from bubble-content-node coords to container-/layout-local coords. + let layoutLocation = location.offsetBy(dx: -1.0, dy: -1.0) + for item in layout.items { + let itemFrame = item.frame + if itemFrame.contains(layoutLocation) { + if let item = item as? InstantPageTextItem, item.selectable { + return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY)) + } else if let item = item as? InstantPageScrollableItem { + let contentOffset = CGPoint.zero + if let (textItem, parentOffset) = item.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) { + return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y)) + } + } else if let item = item as? InstantPageDetailsItem { + for (_, itemNode) in self.visibleItemsWithNodes { + if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item { + if let (textItem, parentOffset) = itemNode.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) { + return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y)) + } + } + } } - - let promise = Promise() - - self.linkProgressDisposable?.dispose() - - if self.linkProgressRange != nil { - self.linkProgressRange = nil + } + } + return nil + } + + private func urlForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, parentOffset: CGPoint, localPoint: CGPoint)? { + guard let (item, parentOffset) = self.textItemAtLocation(point) else { + return nil + } + // Translate bubble-content-node point → text-item-local point. + // (bubble-coords → layout-coords) is `- (1, 1)`; (layout → item-local) is `- item.frame.origin - parentOffset`. + let layoutPoint = point.offsetBy(dx: -1.0, dy: -1.0) + let localPoint = layoutPoint.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y) + guard let urlItem = item.urlAttribute(at: localPoint) else { + return nil + } + return (item, urlItem, parentOffset, localPoint) + } + + private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] { + // Text item returns rects in its local coords; translate back into containerNode-local coords. + // containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives + // *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects. + let originX = item.frame.minX + parentOffset.x + let originY = item.frame.minY + parentOffset.y + return item.linkSelectionRects(at: localPoint).map { rect in + rect.offsetBy(dx: originX, dy: originY) + } + } + + private func makeActivate(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> (() -> Promise?)? { + return { [weak self, weak item] in + guard let self else { + return nil + } + let promise = Promise() + self.linkProgressDisposable?.dispose() + if self.linkProgressRects != nil { + self.linkProgressRects = nil + self.updateLinkProgressState() + } + self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in + guard let self else { + return + } + let updated: [CGRect]? + if value, let item { + updated = self.computeHighlightRects(item: item, parentOffset: parentOffset, localPoint: localPoint) + } else { + updated = nil + } + let changed: Bool + if let lhs = self.linkProgressRects, let rhs = updated { + changed = lhs != rhs + } else { + changed = (self.linkProgressRects == nil) != (updated == nil) + } + if changed { + self.linkProgressRects = updated self.updateLinkProgressState() } - - self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in - guard let self else { - return - } - let updatedRange: NSRange? = value ? urlRange : nil - if self.linkProgressRange != updatedRange { - self.linkProgressRange = updatedRange - self.updateLinkProgressState() - } - }) - - return promise - } - }*/ - - return ChatMessageBubbleContentTapAction(content: .none) + }) + return promise + } } - + + private func updateLinkProgressState() { + guard let messageItem = self.item else { + return + } + if let rects = self.linkProgressRects, !rects.isEmpty { + let linkProgressView: TextLoadingEffectView + if let current = self.linkProgressView { + linkProgressView = current + } else { + linkProgressView = TextLoadingEffectView(frame: CGRect()) + self.linkProgressView = linkProgressView + self.containerNode.view.addSubview(linkProgressView) + } + linkProgressView.frame = self.containerNode.bounds + + let progressColor: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + ? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor + : messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor + + linkProgressView.update(color: progressColor, size: self.containerNode.bounds.size, rects: rects) + } else if let linkProgressView = self.linkProgressView { + self.linkProgressView = nil + linkProgressView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linkProgressView] _ in + linkProgressView?.removeFromSuperview() + }) + } + } + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { return super.hitTest(point, with: event) } override public func updateTouchesAtPoint(_ point: CGPoint?) { + guard let messageItem = self.item else { + return + } + + var rects: [CGRect]? + if let point, let urlHit = self.urlForTapLocation(point) { + rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + } + + if let rects, !rects.isEmpty { + let highlightingNode: LinkHighlightingNode + if let current = self.linkHighlightingNode { + highlightingNode = current + } else { + let color: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + ? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor + : messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor + highlightingNode = LinkHighlightingNode(color: color) + self.linkHighlightingNode = highlightingNode + self.containerNode.insertSubnode(highlightingNode, at: 0) + } + highlightingNode.frame = self.containerNode.bounds + highlightingNode.updateRects(rects) + } else if let highlightingNode = self.linkHighlightingNode { + self.linkHighlightingNode = nil + highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in + highlightingNode?.removeFromSupernode() + }) + } } override public func updateSearchTextHighlightState(text: String?, messages: [MessageIndex]?) { @@ -456,7 +599,57 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode override public func updateIsExtractedToContextPreview(_ value: Bool) { } - + + override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + guard let item = self.item, item.message.id == messageId else { + return nil + } + guard let mediaId = media.id, let layout = self.currentPageLayout?.layout else { + return nil + } + guard let match = self.findInstantPageMedia(in: layout.items, mediaId: mediaId) else { + return nil + } + for (_, itemNode) in self.visibleItemsWithNodes { + if let transition = itemNode.transitionNode(media: match) { + return transition + } + } + return nil + } + + override public func updateHiddenMedia(_ media: [Media]?) -> Bool { + var hiddenMedia: InstantPageMedia? + if let media, !media.isEmpty, let layout = self.currentPageLayout?.layout { + for raw in media { + if let id = raw.id, let match = self.findInstantPageMedia(in: layout.items, mediaId: id) { + hiddenMedia = match + break + } + } + } + for (_, itemNode) in self.visibleItemsWithNodes { + itemNode.updateHiddenMedia(media: hiddenMedia) + } + return hiddenMedia != nil + } + + private func findInstantPageMedia(in items: [InstantPageItem], mediaId: MediaId) -> InstantPageMedia? { + for item in items { + if let detailsItem = item as? InstantPageDetailsItem { + if let found = self.findInstantPageMedia(in: detailsItem.items, mediaId: mediaId) { + return found + } + } + for itemMedia in item.medias { + if itemMedia.media.id == mediaId { + return itemMedia + } + } + } + return nil + } + override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { /*if let statusNode = self.statusNode, !statusNode.isHidden { return statusNode.reactionView(value: value) @@ -475,4 +668,31 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return nil //return self.statusNode } + + private func splitAnchor(_ url: String) -> (base: String, anchor: String?) { + if let anchorRange = url.range(of: "#") { + let anchor = String(url[anchorRange.upperBound...]).removingPercentEncoding + let base = String(url[.. TelegramMediaWebpageLoadedContent? { + guard let item = self.item else { + return nil + } + guard let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage else { + return nil + } + if case let .Loaded(content) = webpage.content { + return content + } + return nil + } + + private func scrollToAnchor(_ anchor: String) { + // TODO: implement intra-page anchor scrolling + let _ = anchor + } } From 528807a24b9b2f72ff2e9bb9e0d6ec9e344ee5b3 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 15:19:51 +0200 Subject: [PATCH 63/69] Spec: InstantPage table borders, stop drawing shared edges twice Documents a two-pass refactor for InstantPageTableItem.drawInTile that draws each interior divider exactly once (top+left of each cell that isn't on the table boundary) plus a single rounded-rect outer-perimeter stroke. Needed now that tableBorderColor is being made semi-transparent (0.25 alpha) by the rich-data chat bubble; current per-cell whole-bounds strokes overdraw shared edges. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...stant-page-table-border-overdraw-design.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md diff --git a/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md b/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md new file mode 100644 index 0000000000..a45db015c2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md @@ -0,0 +1,109 @@ +# InstantPage table borders: stop drawing shared edges twice + +## Problem + +`submodules/InstantPageUI/Sources/InstantPageTableItem.swift` draws table borders per cell: each cell strokes its full perimeter (either via `context.stroke(bounds)` for interior cells, or via `context.drawPath(.stroke)` on a rounded path for the four table-corner cells). Every interior grid line is the boundary between two adjacent cells, so it is stroked twice. + +This was visually invisible while `tableBorderColor` was opaque. With the in-flight change in `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` setting `tableBorderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25)`, double-stroked interior lines composite to ~44% alpha while the once-stroked outer perimeter shows at the intended 25%. The grid looks darker than the frame. + +## Goal + +Each border line — interior dividers and the outer perimeter — is stroked exactly once. + +## Non-goals + +- No change to `cell.adjacentSides`, `TableSide`, `tableCornerRadius`, `tableBorderWidth`, or any frame-layout code in `layoutTableItem`. +- No public API change. +- No change to fill behavior (header rows, striped rows, rounded corners on outer cells). +- No change anywhere outside `InstantPageTableItem.swift`. + +## Design + +`drawInTile(context:)` is restructured into two passes. + +### Pass 1: per cell (existing loop) + +The current early `if cell.cell.text == nil { continue }` is removed — empty cells must still contribute border lines (see "Empty cells preserve divider continuity" below). Each piece of per-cell work is gated explicitly instead: + +For each cell, in order: + +1. **Fill.** If `cell.filled && cell.cell.text != nil` (the `text != nil` gate preserves today's behavior of not filling empty cells, including in striped/header rows): + - If `cell.adjacentSides` is non-empty, fill with the rounded path (built from `byRoundingCorners: cell.adjacentSides.uiRectCorner`, `cornerRadii: tableCornerRadius`). This preserves the rounded fill on the four table-corner cells. + - Otherwise, `context.fill(bounds)`. +2. **Interior dividers.** If `self.borderWidth > 0.0` (no `text != nil` gate — empty cells still draw their dividers): + - If `!cell.adjacentSides.contains(.top)`, stroke the cell's top edge — a line from `(0, 0)` to `(cell.frame.width, 0)` in cell-local coordinates. + - If `!cell.adjacentSides.contains(.left)`, stroke the cell's left edge — a line from `(0, 0)` to `(0, cell.frame.height)` in cell-local coordinates. + - No `.right` or `.bottom` line is drawn per cell; both are owned by Pass 2. +3. **Text.** `cell.textItem?.drawInTile(context: context)`, unchanged. Already gated on `textItem` being non-nil. + +The `context.translateBy` / `saveGState` / `restoreGState` wrapper around each cell stays the same; the line strokes happen inside the per-cell translation, in cell-local coordinates. + +#### Empty cells preserve divider continuity + +Today, `cell.cell.text == nil` cells are skipped entirely. Under the existing "stroke whole bounds" model that's harmless — adjacent non-empty cells stroke their full perimeters and cover the dividers around the empty cell with their own bottom/right strokes. + +Under the new "always top+left, never bottom+right" convention, an empty cell's omitted top would leave a gap in the divider between it and the row above (the row-above's bottom is no longer stroked, and the empty cell isn't there to stroke its top). Symmetric for left. So Pass 1's divider-line block must run for empty cells too. Fill and text remain gated to preserve existing visuals — the only behavior change for empty cells is that their top/left dividers are now drawn explicitly, restoring continuity that was previously provided incidentally by adjacent cells' overdraw. + +### Pass 2: outer border (new, runs once after the loop) + +If `self.borderWidth > 0.0`: + +```swift +let outerRect = CGRect( + x: self.borderWidth / 2.0, + y: self.borderWidth / 2.0, + width: self.totalWidth - self.borderWidth, + height: self.frame.height - self.borderWidth +) +let outerPath = UIBezierPath(roundedRect: outerRect, cornerRadius: tableCornerRadius) +context.addPath(outerPath.cgPath) +context.strokePath() +``` + +Coordinates: `drawInTile`'s context is in the table-content coordinate space (origin at the table's top-left, size `totalWidth × frame.height`), matching `InstantPageScrollableContentNode`'s draw setup. Cells in `layoutTableItem` start at `origin = (borderWidth/2, borderWidth/2)`, so the outer rect inset by `borderWidth/2` aligns the stroke center exactly on the cells' outer perimeter. + +### Stroke setup + +`context.setStrokeColor(self.theme.tableBorderColor.cgColor)` and `context.setLineWidth(self.borderWidth)` are set once at the top of `drawInTile`, before the per-cell loop, instead of being re-set on every iteration. They are invariant per table. `context.setFillColor(self.theme.tableHeaderColor.cgColor)` is set once at the top for the same reason. (Each cell's `saveGState` / `restoreGState` would otherwise discard and re-set these every iteration; hoisting is functionally identical and simpler.) + +### Draw order + +Outer border is stroked **after** every cell's fill. With a semi-transparent border this means the border composites on top of any underlying header/striped fill at the four rounded corners, matching today's `.fillStroke` semantics where the stroke draws after the fill. + +## Why this gives every line exactly once + +- **Interior horizontal divider between rows R and R+1.** This is the top edge of every cell starting in row R+1. Cells in row R+1 do not have `.top ∈ adjacentSides` (only row 0 does), so they all draw it. Cells in row R do not draw their bottom edge in this pass. +- **Interior vertical divider between columns C and C+1.** Symmetric: drawn as the left edge of every cell starting in column C+1. +- **Outer top edge.** Row 0 cells have `.top ∈ adjacentSides`, so they skip their top edge in Pass 1. The outer rounded rect stroke draws it once in Pass 2. +- **Outer left / right / bottom edges.** Same — Pass 1 never draws right or bottom; Pass 1 skips left when `.left ∈ adjacentSides`; Pass 2's outer stroke draws all four perimeter sides. + +### colspan / rowspan + +`adjacentSides` already encodes "this cell touches the table's outer boundary on this side" for the layout code's existing semantics. The new drawing logic depends only on `.top` and `.left`, both of which are computed from the cell's *starting* row/column (`i == 0` and `k == 0` checks in `layoutTableItem`). Spanning cells therefore behave correctly: + +- A colspan>1 cell starting at column 0 has `.left ∈ adjacentSides` → skips its left in Pass 1 (the outer border draws it). +- The next cell to its right starts at the column where the spanning cell ends; that next cell draws its own left edge, which is the spanning cell's right boundary. +- Inside the spanning cell's footprint there is no other cell to draw an internal divider, so no internal divider is drawn — correct. +- Same logic in the rowspan dimension via `.top`. + +The existing quirk where a rowspan>1 cell starting at the last row has `.bottom ∈ adjacentSides` (instead of computing on its end row) does not affect Pass 1, which never reads `.bottom`. + +### Edge cases + +- **No border (`borderWidth == 0`)**: Pass 1's interior-divider block is skipped; Pass 2 is skipped. Fill behavior unchanged. +- **Single-row table**: every cell has `.top ∈ adjacentSides`. Pass 1 draws no top edges (correct — no interior horizontal divider to draw). Outer border draws all four sides. +- **Single-column table**: every cell has `.left ∈ adjacentSides`. Pass 1 draws no left edges. Outer border draws all four sides. +- **1×1 table**: one cell with all four sides in `adjacentSides`. Pass 1 draws nothing. Pass 2 draws the outer rounded rect. +- **Cells without `cell.text`**: see "Empty cells preserve divider continuity" above. The early continue is removed; fill and text remain gated on `text != nil` (preserving today's no-fill behavior for empty cells); dividers run unconditionally so divider continuity is preserved. +- **Empty `cells`**: `totalWidth` is 0 in this case (`InstantPageTableItem(frame: CGRect(), totalWidth: 0.0, ...)` from the `rows.count == 0` early return in `layoutTableItem`). The outer rect would have negative width if `borderWidth > 0`. Guard Pass 2 with `self.totalWidth > 0` (or skip the whole `drawInTile` body when the cell list is empty — same effect). + +## File to modify + +- `submodules/InstantPageUI/Sources/InstantPageTableItem.swift`, function `drawInTile(context:)` only. + +## Verification + +This is a visual change with no tests. Verification path: + +1. Full Bazel build per CLAUDE.md, `--continueOnError`, `--configuration=debug_sim_arm64`. +2. Open an Instant View page that contains a table inside the rich-data chat bubble (where `tableBorderColor.withMultipliedAlpha(0.25)` is in effect) and visually confirm interior gridlines and outer perimeter are the same alpha. Also open a non-bubble Instant View page (where `tableBorderColor` is opaque) and confirm no visual regression. From f29af03cd7aa11e290561c2e11933720d1ae7cee Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 15:59:46 +0200 Subject: [PATCH 64/69] InstantPage: underline rendering Render underline runs in layoutTextItemWithString and position them below the baseline rather than above the text. --- ...instant-page-underline-rendering-design.md | 101 ++++++++++++++++++ .../Sources/InstantPageTextItem.swift | 45 +++++++- 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md diff --git a/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md b/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md new file mode 100644 index 0000000000..cd87db6704 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md @@ -0,0 +1,101 @@ +# InstantPage underline rendering + +## Problem + +`layoutTextItemWithString` in `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` does not handle the `NSAttributedString.Key.underlineStyle` attribute. Underline runs are produced upstream by `InstantPageTextStyleStack.textAttributes()` (`InstantPageTextStyleStack.swift:194-202`) for two distinct sources: + +1. Explicit `RichText.underline` runs (push at `InstantPageTextItem.swift:607`, plus `:628` and `:657` for related cases). +2. Links whose computed foreground color matches the body-text color — the styleStack falls back to underlining them so they remain distinguishable (`InstantPageTextStyleStack.swift:200-201`). + +The attribute lands on the attributed string in both cases, but the per-line attribute enumerator at `InstantPageTextItem.swift:915-938` only branches on `strikethroughStyle`, `InstantPageMarkerColorAttribute`, and `InstantPageAnchorAttribute`. Underline runs are silently dropped during layout, so they never get drawn. + +The canonical handling pattern lives in `submodules/Display/Source/TextNode.swift:2061-2066` (collection during layout) and `:2619-2638` (manual draw at draw time). `TextNode` deliberately draws underlines manually (`drawUnderlinesManually = true` at `:216`) rather than letting Core Text render them, because CT's underline rendering has historic positioning, color, and clipping issues across glyph clusters and emoji. + +## Goal + +Render underlines in InstantPage articles wherever the styleStack emits `underlineStyle`, matching `TextNode.swift` line-for-line so a future reader sees the same shape in both files. + +## Non-goals + +- Wavy or double underline support — the InstantPage styleStack only emits `NSUnderlineStyle.single`. +- Changes to `InstantPageTextStyleStack` — the attribute it produces is already correct. +- Changes to the existing strikethrough draw's reliance on the context's residual fill color — out of scope, not regressed. +- Changes to `attributesAtPoint` or selection-rect logic — these read attributes directly off `attributedString`, so they already work for underlined ranges. + +## Design + +### New type + +In `InstantPageTextItem.swift`, alongside `InstantPageTextStrikethroughItem`: + +```swift +struct InstantPageTextUnderlineItem { + let frame: CGRect + let range: NSRange + let color: UIColor? +} +``` + +`color` carries an optional `NSAttributedString.Key.underlineColor`. There is no `style` field — `.single` is the only value the styleStack emits. `range` is needed at draw time to look up `foregroundColor` per-range when `underlineColor` is absent. + +### Line storage + +Add `let underlineItems: [InstantPageTextUnderlineItem]` to `InstantPageTextLine`, with a matching init parameter alongside `strikethroughItems`. There is exactly one construction site for `InstantPageTextLine` (`InstantPageTextItem.swift:970`), so updating the initializer is local. + +### Collection (layoutTextItemWithString) + +In the `enumerateAttributes` loop currently at `InstantPageTextItem.swift:915-938`, add a parallel branch that mirrors `TextNode.swift:2061-2066`: + +```swift +if let _ = attributes[NSAttributedString.Key.underlineStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + underlineItems.append(InstantPageTextUnderlineItem( + frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight), + range: range, + color: attributes[NSAttributedString.Key.underlineColor] as? UIColor + )) +} +``` + +Geometry is verbatim the strikethrough branch's — same `lowerX`/`upperX` clamp and the same `workingLineOrigin.x + x` offset. The collection is independent of strikethrough; both branches can fire on the same range. + +### Draw (drawInTile) + +After the strikethrough draw block at `InstantPageTextItem.swift:261-266`, add: + +```swift +if !line.underlineItems.isEmpty { + for item in line.underlineItems { + var color: UIColor? = item.color + if color == nil { + self.attributedString.enumerateAttributes(in: item.range, options: []) { attributes, _, _ in + if let foreground = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { + color = foreground + } + } + } + if let color { + context.setFillColor(color.cgColor) + } + let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0) + context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + 1.0, width: itemFrame.size.width, height: 1.0)) + } +} +``` + +Color resolution order (`underlineColor` → per-range `foregroundColor`) and position rule (`y: minY + 1.0`, `height: 1.0`) match `TextNode.swift:2624-2638` exactly. + +The `setFillColor` call is gated on a non-nil resolved color so we do not silently flip an unrelated drawing's fill color if no foreground attribute is found in the range. In practice the attributed string always carries a `foregroundColor` (set unconditionally by the styleStack at `InstantPageTextStyleStack.swift:198-211`), so the gate is defense-in-depth, not a hot path. + +## Verification + +- Full Bazel build via `Make.py … --configuration=debug_sim_arm64`. +- Manual smoke against an article whose body contains an explicit `` block, and a separate article whose link color matches the body color (the styleStack's link-fallback case). + +No unit tests exist in this project (per `CLAUDE.md`). + +## Risk + +Additive: a new struct, a new optional field on `InstantPageTextLine`, one new branch in the layout enumerator, one new draw block. No public API changes, no signature changes outside `InstantPageTextItem.swift`. Runs without `underlineStyle` are unaffected. diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index a66ff44839..f3ac903e2e 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -33,6 +33,12 @@ struct InstantPageTextStrikethroughItem { let frame: CGRect } +struct InstantPageTextUnderlineItem { + let frame: CGRect + let range: NSRange + let color: UIColor? +} + struct InstantPageTextImageItem { let frame: CGRect let range: NSRange @@ -68,17 +74,19 @@ public final class InstantPageTextLine { let range: NSRange public let frame: CGRect let strikethroughItems: [InstantPageTextStrikethroughItem] + let underlineItems: [InstantPageTextUnderlineItem] let markedItems: [InstantPageTextMarkedItem] let imageItems: [InstantPageTextImageItem] let formulaItems: [InstantPageTextFormulaRun] public let anchorItems: [InstantPageTextAnchorItem] let isRTL: Bool - - init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool) { + + init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], underlineItems: [InstantPageTextUnderlineItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool) { self.line = line self.range = range self.frame = frame self.strikethroughItems = strikethroughItems + self.underlineItems = underlineItems self.markedItems = markedItems self.imageItems = imageItems self.formulaItems = formulaItems @@ -264,6 +272,24 @@ public final class InstantPageTextItem: InstantPageItem { context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((lineFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0)) } } + + if !line.underlineItems.isEmpty { + for item in line.underlineItems { + var color: UIColor? = item.color + if color == nil { + self.attributedString.enumerateAttributes(in: item.range, options: []) { attributes, _, _ in + if let foreground = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { + color = foreground + } + } + } + if let color { + context.setFillColor(color.cgColor) + } + let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0) + context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 1.0, width: itemFrame.size.width, height: 1.0)) + } + } } context.restoreGState() @@ -909,9 +935,10 @@ func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFlo } var strikethroughItems: [InstantPageTextStrikethroughItem] = [] + var underlineItems: [InstantPageTextUnderlineItem] = [] var markedItems: [InstantPageTextMarkedItem] = [] var anchorItems: [InstantPageTextAnchorItem] = [] - + string.enumerateAttributes(in: lineRange, options: []) { attributes, range, _ in if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) @@ -919,6 +946,16 @@ func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFlo let x = lowerX < upperX ? lowerX : upperX strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight))) } + if let _ = attributes[NSAttributedString.Key.underlineStyle] { + let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) + let x = lowerX < upperX ? lowerX : upperX + underlineItems.append(InstantPageTextUnderlineItem( + frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight), + range: range, + color: attributes[NSAttributedString.Key.underlineColor] as? UIColor + )) + } if let color = attributes[NSAttributedString.Key.init(rawValue: InstantPageMarkerColorAttribute)] as? UIColor { var lineHeight = fontLineHeight var delta: CGFloat = 0.0 @@ -967,7 +1004,7 @@ func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFlo } } } - let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, markedItems: markedItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, anchorItems: anchorItems, isRTL: isRTL) + let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, anchorItems: anchorItems, isRTL: isRTL) lines.append(textLine) imageItems.append(contentsOf: lineImageItems) From fdb2f369ec4a0116232cd6984b31279abd514548 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 17:55:19 +0200 Subject: [PATCH 65/69] Rich bubble: scrollToAnchor + getAnchorRect Adds a base getAnchorRect on ChatMessageBubbleContentNode, the rich-bubble override (including titleHeight in details recursion), bubble-item forwarding to content nodes, ChatControllerInteraction.scrollToMessageIdWithAnchor, and a scrollToAnchor that lands the anchor at the top of the content area / its line. Threads anchor/scroll params through ChatController and related call sites. --- ...05-01-rich-data-bubble-scroll-to-anchor.md | 504 ++++++++++++++++++ ...ich-data-bubble-scroll-to-anchor-design.md | 149 ++++++ .../Sources/BrowserBookmarksScreen.swift | 4 +- .../Sources/BrowserInstantPageContent.swift | 3 +- submodules/Display/Source/ListView.swift | 3 + .../Sources/InstantPageControllerNode.swift | 4 +- .../Sources/InstantPageLayout.swift | 49 +- .../Sources/InstantPageTextItem.swift | 2 +- .../Sources/InstantPageTextStyleStack.swift | 3 + .../Sources/InstantPageTheme.swift | 62 ++- .../Sources/ChatBotInfoItem.swift | 2 +- .../ChatMessageBubbleContentNode.swift | 7 +- .../Sources/ChatMessageBubbleItemNode.swift | 70 ++- ...ChatMessageRichDataBubbleContentNode.swift | 198 ++++++- .../ChatMessageTextBubbleContentNode.swift | 10 +- .../ChatRecentActionsControllerNode.swift | 4 +- .../ChatSendAudioMessageContextPreview.swift | 6 +- .../Sources/ChatControllerInteraction.swift | 26 +- .../Sources/InteractiveTextComponent.swift | 7 +- .../Sources/PeerInfoScreen.swift | 4 +- .../TelegramUI/Sources/ChatController.swift | 94 +++- .../Sources/ChatHistoryListNode.swift | 2 +- .../TelegramUI/Sources/OpenChatMessage.swift | 4 +- .../OverlayAudioPlayerControllerNode.swift | 4 +- .../Sources/SharedAccountContext.swift | 6 +- .../InstantPagePresentationSettings.swift | 21 +- 26 files changed, 1148 insertions(+), 100 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md create mode 100644 docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md diff --git a/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md b/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md new file mode 100644 index 0000000000..bcdfa83373 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md @@ -0,0 +1,504 @@ +# Rich-Data Bubble scrollToAnchor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `ChatMessageRichDataBubbleContentNode.scrollToAnchor` actually scroll the chat history so that an in-page anchor's line lands at the top of the visible content area. + +**Architecture:** Mirror the existing `getQuoteRect` mechanism. The rich-data bubble exposes `getAnchorRect(anchor:)`, the bubble item node forwards to it. A new `ChatControllerInteraction.scrollToMessageIdWithAnchor` closure walks visible items via `forEachVisibleItemNode` (the bubble is necessarily at least partially visible because the user tapped a link in it), reads the anchor's item-local y, and routes through `ChatHistoryListNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom` places the item so the anchor lands at the visual top of the content area; it works uniformly for short and tall items, where `.center(.custom)` is bypassed for items that fit in the content area. + +**Tech Stack:** Swift, Bazel build, AsyncDisplayKit. No unit tests in this project — verification is a full Bazel build. + +**Spec:** [docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md](../specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md) + +**Build command (run after each task):** + +```sh +source ~/.zshrc 2>/dev/null; \ +python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent \ + --buildNumber=1 --configuration=debug_sim_arm64 +``` + +After Tasks 1–3 the build must be green; the feature is not yet live (no callers use the new methods). After Task 4 the new closure exists and is implemented but the bubble still routes through the old stub. After Task 5 the feature is live. + +--- + +## Task 1: Add base `getAnchorRect` to `ChatMessageBubbleContentNode` + +This makes `getAnchorRect(anchor:)` callable on every content node (returns `nil` by default) so the iteration in `ChatMessageBubbleItemNode` doesn't need a type-test. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift` + +- [ ] **Step 1: Add the base method** + +Open the file. Find the existing `open func transitionNode(messageId:media:adjustRect:) -> (...)` definition (around line 261). Add a new method directly after its closing brace: + +```swift +open func getAnchorRect(anchor: String) -> CGRect? { + return nil +} +``` + +The result is that the file should contain, contiguously: + +```swift +open func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return nil +} + +open func getAnchorRect(anchor: String) -> CGRect? { + return nil +} + +open func updateHiddenMedia(_ media: [Media]?) -> Bool { + return false +} +``` + +- [ ] **Step 2: Build** + +Run the build command at the top of this plan. +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift +git commit -m "$(cat <<'EOF' +ChatMessageBubbleContentNode: add base getAnchorRect + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Override `getAnchorRect` in `ChatMessageRichDataBubbleContentNode` + +Walks the cached instant-page layout and returns the rect of an anchor (in the bubble content node's coordinate space) without triggering any side-effects. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Add the override and recursive helper** + +Open the file. Find the existing `private func splitAnchor(_ url: String)` (around line 774). Insert two new methods directly above it (so the new public override comes before the private string helpers but after `findInstantPageMedia`): + +```swift +override public func getAnchorRect(anchor: String) -> CGRect? { + guard let layout = self.currentPageLayout?.layout else { + return nil + } + if let rect = self.anchorRect(in: layout.items, anchor: anchor, baseY: 0.0) { + // Translate from layout/containerNode coords to bubble-content-node coords. + // containerNode is offset by (1, 1) from the bubble content node. + return rect.offsetBy(dx: 1.0, dy: 1.0) + } + return nil +} + +private func anchorRect(in items: [InstantPageItem], anchor: String, baseY: CGFloat) -> CGRect? { + for item in items { + if let item = item as? InstantPageAnchorItem, item.anchor == anchor { + return CGRect(x: item.frame.minX, y: baseY + item.frame.minY, width: 1.0, height: 1.0) + } else if let item = item as? InstantPageTextItem { + if let (lineIndex, _) = item.anchors[anchor] { + let lineFrame = item.lines[lineIndex].frame + return CGRect(x: item.frame.minX + lineFrame.minX, y: baseY + item.frame.minY + lineFrame.minY, width: lineFrame.width, height: lineFrame.height) + } + } else if let item = item as? InstantPageTableItem { + if let (offset, _) = item.anchors[anchor] { + return CGRect(x: item.frame.minX, y: baseY + item.frame.minY + offset, width: item.frame.width, height: 1.0) + } + } else if let item = item as? InstantPageDetailsItem { + // Inner items are laid out below the title bar, so the recursive base + // must include titleHeight (mirrors InstantPageDetailsNode.linkSelectionRects). + if let rect = self.anchorRect(in: item.items, anchor: anchor, baseY: baseY + item.frame.minY + item.titleHeight) { + return rect + } + } + } + return nil +} +``` + +Note: the existing file already imports `InstantPageUI`, so `InstantPageItem`, `InstantPageAnchorItem`, `InstantPageTextItem`, `InstantPageTableItem`, and `InstantPageDetailsItem` resolve. `InstantPageTextItem.anchors` is typed `[String: (Int, Bool)]`, `InstantPageTableItem.anchors` is `[String: (CGFloat, Bool)]` — destructure accordingly. + +- [ ] **Step 2: Build** + +Run the build command. +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "$(cat <<'EOF' +Rich bubble: add getAnchorRect override + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Forward `getAnchorRect` from `ChatMessageBubbleItemNode` + +Iterates content nodes and converts the rect to the bubble item node's coordinate space. Mirrors the existing `getQuoteRect` shape exactly. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift` + +- [ ] **Step 1: Add the public forwarder** + +Open the file. Find the existing `public func getQuoteRect(quote: String, offset: Int?) -> CGRect?` (around line 7237). Insert a new method directly after its closing brace, before `public func getInnerReplySubjectRect(...)`: + +```swift +public func getAnchorRect(anchor: String) -> CGRect? { + for contentNode in self.contentNodes { + if let result = contentNode.getAnchorRect(anchor: anchor) { + return contentNode.view.convert(result, to: self.view) + } + } + return nil +} +``` + +- [ ] **Step 2: Build** + +Run the build command. +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +git commit -m "$(cat <<'EOF' +Bubble item: forward getAnchorRect to content nodes + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Add `scrollToMessageIdWithAnchor` closure (declaration + 7 sites) + +Adds the new `(MessageIndex, String) -> Void` closure to `ChatControllerInteraction`, the real implementation in `ChatController.swift`, and no-op stubs at the six other call sites. After this task the build is green and the closure works end-to-end on the chat-controller side; the rich-data bubble still routes through the old stub so the feature is not yet live. + +**Files (8 edits):** +- Modify: `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` (3 edits: field, init param, assignment) +- Modify: `submodules/TelegramUI/Sources/ChatController.swift` (real implementation) +- Modify: `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift` (no-op stub) +- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` (no-op stub) +- Modify: `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` (no-op stub) +- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` (no-op stub) +- Modify: `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift` (no-op stub) +- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift` (no-op stub) + +- [ ] **Step 1: Add the field on `ChatControllerInteraction`** + +In `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift`, find: + +```swift +public let scrollToMessageId: (MessageIndex, CGFloat) -> Void +``` + +(around line 311). Insert directly after it: + +```swift +public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void +``` + +- [ ] **Step 2: Add the init parameter** + +In the same file, find the init parameter list (around line 490): + +```swift +scrollToMessageId: @escaping (MessageIndex, CGFloat) -> Void, +``` + +Insert directly after it: + +```swift +scrollToMessageIdWithAnchor: @escaping (MessageIndex, String) -> Void, +``` + +- [ ] **Step 3: Add the init assignment** + +In the same file, find (around line 622): + +```swift +self.scrollToMessageId = scrollToMessageId +``` + +Insert directly after it: + +```swift +self.scrollToMessageIdWithAnchor = scrollToMessageIdWithAnchor +``` + +- [ ] **Step 4: Add real implementation in `ChatController.swift`** + +In `submodules/TelegramUI/Sources/ChatController.swift`, find (around line 5397): + +```swift +}, scrollToMessageId: { [weak self] index, offset in + self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset) +}, navigateToStory: { [weak self] message, storyId in +``` + +Insert a new closure between `scrollToMessageId` and `navigateToStory`: + +```swift +}, scrollToMessageId: { [weak self] index, offset in + self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset) +}, scrollToMessageIdWithAnchor: { [weak self] index, anchor in + guard let self else { + return + } + var anchorY: CGFloat? + self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in + guard anchorY == nil else { + return + } + if let itemNode = itemNode as? ChatMessageBubbleItemNode, + itemNode.item?.message.id == index.id, + let rect = itemNode.getAnchorRect(anchor: anchor) { + anchorY = rect.minY + } + } + if let anchorY { + self.chatDisplayNode.historyNode.scrollToMessage( + from: index, to: index, + animated: true, highlight: false, + scrollPosition: .bottom(anchorY) + ) + } else { + self.chatDisplayNode.historyNode.scrollToMessage(index: index) + } +}, navigateToStory: { [weak self] message, storyId in +``` + +`scrollToMessage(from:to:animated:highlight:scrollPosition:)` is the existing public method on `ChatHistoryListNode` (declared at line 3585 in `ChatHistoryListNode.swift`); `quote`, `subject`, and `setupReply` use their default values. `ChatMessageBubbleItemNode` is already imported at the top of `ChatController.swift`. The `forEachVisibleItemNode` walk is sound because tapping the in-page anchor link requires the bubble to be at least partially visible. + +- [ ] **Step 5: Add no-op stub in `BrowserBookmarksScreen.swift`** + +In `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift`, find (around line 183): + +```swift +}, scrollToMessageId: { _, _ in +``` + +Replace with: + +```swift +}, scrollToMessageId: { _, _ in +}, scrollToMessageIdWithAnchor: { _, _ in +``` + +- [ ] **Step 6: Add no-op stub in `ChatRecentActionsControllerNode.swift`** + +In `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift`, find (around line 660): + +```swift +}, scrollToMessageId: { _, _ in +``` + +Replace with: + +```swift +}, scrollToMessageId: { _, _ in +}, scrollToMessageIdWithAnchor: { _, _ in +``` + +- [ ] **Step 7: Add no-op stub in `ChatSendAudioMessageContextPreview.swift`** + +In `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift`, find (around line 507): + +```swift +}, scrollToMessageId: { _, _ in +``` + +Replace with: + +```swift +}, scrollToMessageId: { _, _ in +}, scrollToMessageIdWithAnchor: { _, _ in +``` + +- [ ] **Step 8: Add no-op stub in `PeerInfoScreen.swift`** + +In `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift`, find (around line 1278): + +```swift +}, scrollToMessageId: { _, _ in +``` + +Replace with: + +```swift +}, scrollToMessageId: { _, _ in +}, scrollToMessageIdWithAnchor: { _, _ in +``` + +- [ ] **Step 9: Add no-op stub in `OverlayAudioPlayerControllerNode.swift`** + +In `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift`, find (around line 252): + +```swift +}, scrollToMessageId: { _, _ in +``` + +Replace with: + +```swift +}, scrollToMessageId: { _, _ in +}, scrollToMessageIdWithAnchor: { _, _ in +``` + +- [ ] **Step 10: Add no-op stub in `SharedAccountContext.swift`** + +In `submodules/TelegramUI/Sources/SharedAccountContext.swift`, find (around line 2565): + +```swift +scrollToMessageId: { _, _ in +``` + +(Note: this site has no leading `}, ` because it is the first argument on its line — verify with `grep -n "scrollToMessageId:" submodules/TelegramUI/Sources/SharedAccountContext.swift`.) + +Insert a new closure directly after the closing `}` of the `scrollToMessageId` stub. If the original looks like: + +```swift +scrollToMessageId: { _, _ in +}, +navigateToStory: { _, _ in +``` + +it should become: + +```swift +scrollToMessageId: { _, _ in +}, +scrollToMessageIdWithAnchor: { _, _ in +}, +navigateToStory: { _, _ in +``` + +Match the surrounding indentation and trailing-comma style of the file. + +- [ ] **Step 11: Build** + +Run the build command. +Expected: build succeeds. Any compile error in this task means a stub site was missed or the closure type was mismatched — search for `scrollToMessageId:` again and confirm every site has a corresponding `scrollToMessageIdWithAnchor:`. + +- [ ] **Step 12: Commit** + +```sh +git add \ + submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift \ + submodules/TelegramUI/Sources/ChatController.swift \ + submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift \ + submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift \ + submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift \ + submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \ + submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift \ + submodules/TelegramUI/Sources/SharedAccountContext.swift +git commit -m "$(cat <<'EOF' +ChatControllerInteraction: add scrollToMessageIdWithAnchor closure + +Routes through ChatHistoryListNode.scrollToMessage with a custom +.center(.custom) callback that asks the bubble item for the anchor +rect's midY. Six existing no-op interaction sites get matching +no-op stubs. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Wire up `scrollToAnchor` in the rich-data bubble + +Replace the stub body so that taps on in-page anchor links actually scroll. After this task the feature is live end-to-end. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Replace `scrollToAnchor` body** + +In `ChatMessageRichDataBubbleContentNode.swift`, find (around line 796): + +```swift +private func scrollToAnchor(_ anchor: String) { + guard let item = self.item else { + return + } + // 0.0 is offset + item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) +} +``` + +Replace with: + +```swift +private func scrollToAnchor(_ anchor: String) { + guard let item = self.item else { + return + } + if anchor.isEmpty { + item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) + } else { + item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) + } +} +``` + +The empty-anchor branch keeps the existing "scroll to message top" behavior for `#` URLs with no fragment. + +- [ ] **Step 2: Build** + +Run the build command. +Expected: build succeeds. + +- [ ] **Step 3: Manual smoke test** + +This project has no unit tests. Smoke-test in the simulator: + +1. Launch the app on the iOS simulator. +2. Open a chat that contains a webpage message rendered as a rich-data bubble. Good source: a Wikipedia article URL whose Telegram instant-page render contains in-page section/footnote links (e.g., the "Contents" section or the `[1]`-style citation links). +3. Tap a section/footnote link inside the bubble. +4. Expected: the chat scrolls so that the target line of the bubble is centered in the visible area. If the bubble is partially off-screen, the chat scrolls to bring the line into view. +5. Tap a `#`-only link (no fragment) if you can find one. Expected: chat scrolls to the message top (existing pre-task behavior preserved). + +If the scroll doesn't land where expected, double-check the coord conversions in Task 2 (`+1, +1` for `containerNode` inset) and Task 3 (`contentNode.view.convert(rect, to: self.view)`). + +- [ ] **Step 4: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "$(cat <<'EOF' +Rich bubble: scrollToAnchor scrolls to anchor's line + +Empty anchor keeps the previous scroll-to-message-top behavior. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Done + +All five tasks complete leaves: +- Each commit independently builds. +- The feature is live: tapping an in-page anchor link inside a rich-data bubble scrolls the chat to center the target line. +- Reference popups and details expansion are deferred (per spec). diff --git a/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md b/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md new file mode 100644 index 0000000000..bdd859d8a0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md @@ -0,0 +1,149 @@ +# ChatMessageRichDataBubbleContentNode.scrollToAnchor + +## Background + +`ChatMessageRichDataBubbleContentNode` renders a webpage's `instantPage` inline inside a chat message bubble (the same layout/tile machinery as `InstantPageControllerNode`, but embedded as a content node of `ChatMessageBubbleItemNode`). + +The bubble already detects in-page anchor links (URL with a `#fragment`) when its base URL matches the current loaded webpage and routes them to a private `scrollToAnchor(_ anchor: String)`. That method is a stub today: + +```swift +private func scrollToAnchor(_ anchor: String) { + guard let item = self.item else { return } + item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) +} +``` + +`ChatHistoryListNode.scrollToMessage(index:offset:)` ignores the offset, so the anchor name is dropped and the bubble simply scrolls to the message top. Tapping a footnote / section link inside a long instant-page bubble does nothing useful when the target is below the fold. + +## Goal + +When an in-page anchor inside a rich-data bubble is tapped, scroll the chat history so the anchor's line lands at the top of the visible content area. + +## Non-goals (explicitly deferred) + +- **Reference popup**: `InstantPageControllerNode.scrollToAnchor` shows `InstantPageReferenceController` as an overlay when the anchor is a footnote-style "reference" (text item, non-empty anchor text). We will simply scroll to the line containing the reference instead. No popup. +- **Collapsed details expansion**: The bubble already no-ops `updateDetailsExpanded`, so the runtime never toggles `InstantPageDetailsItem` state. We compute the rect for anchors inside details items as if they were expanded; no expansion side-effect is performed. Worst case for a layout-collapsed details anchor is a slightly-off scroll target — acceptable for v1. + +## Approach + +Add a `getAnchorRect(anchor:)` resolver on the bubble (mirrors `getQuoteRect`'s shape: base no-op, rich-data override walks the layout, bubble item forwards to content nodes). The chat controller then uses `forEachVisibleItemNode` to find the bubble being scrolled to (it is by definition partially visible — the user tapped a link in it), reads the anchor's item-local y, and dispatches `historyNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom(additionalOffset)` places the item so its frame.maxY lands at `(visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (item-local-y of the anchor's top edge), the anchor renders at the visual top of the chat's content area regardless of whether the item is short or tall. (`.center(.custom)` was the original pick but is bypassed for items that fit in the content area, and the rotation maps "list-coord low" to "visual bottom" in chat lists, so `.bottom` is the more uniform primitive here.) + +### Components + +#### 1. `ChatMessageBubbleContentNode.getAnchorRect(anchor:)` — base, default `nil` + +Add an `open func getAnchorRect(anchor: String) -> CGRect? { return nil }` to the base class so callers don't need to type-test every content node. + +#### 2. `ChatMessageRichDataBubbleContentNode.getAnchorRect(anchor:)` — override + +Walk `self.currentPageLayout?.layout.items`, mirroring the cases in `InstantPageControllerNode.findAnchorItem`: +- `InstantPageAnchorItem` with matching `anchor` → return a 1pt rect at the item's `frame.origin`. +- `InstantPageTextItem`, `item.anchors[anchor] == (lineIndex, _)` → return the rect of `item.lines[lineIndex].frame`, offset by `item.frame.origin`. +- `InstantPageTableItem`, `item.anchors[anchor] == (offset, _)` → return a 1pt-tall row-width rect at `item.frame.origin + (0, offset)`. +- `InstantPageDetailsItem` → recurse into `item.items` with `baseY` increased by `item.frame.minY + item.titleHeight` (inner items live below the title bar; mirrors `InstantPageDetailsNode.linkSelectionRects`). Per non-goal #2, no expand side-effect. + +The walk returns coordinates in *layout space* (= `containerNode`-local). The bubble's `containerNode` is offset `(1, 1)` from the bubble content node, so add `(1, 1)` before returning. The returned rect is in `ChatMessageRichDataBubbleContentNode`'s own coordinate space (its `view`). + +If no anchor matches anywhere in the tree, return `nil`. + +#### 3. `ChatMessageBubbleItemNode.getAnchorRect(anchor:)` — public + +Add next to the existing `getQuoteRect(quote:offset:)`. Iterate `self.contentNodes`; for each, call `contentNode.getAnchorRect(anchor:)` and, if non-nil, return `contentNode.view.convert(rect, to: self.view)`. Return `nil` if no content node knows the anchor. + +#### 4. `ChatControllerInteraction.scrollToMessageIdWithAnchor` — new closure + +Add a new public closure on `ChatControllerInteraction`: + +```swift +public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void +``` + +Wire through the initializer (parameter, assignment) alongside the existing `scrollToMessageId`. The existing `scrollToMessageId(MessageIndex, CGFloat)` closure stays untouched — its 7 callers (incl. 6 no-op stubs) need no signature change. + +Add no-op stubs `scrollToMessageIdWithAnchor: { _, _ in }` at the six existing no-op sites: +- `BrowserUI/Sources/BrowserBookmarksScreen.swift` +- `Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` +- `Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` +- `Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` +- `TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift` +- `TelegramUI/Sources/SharedAccountContext.swift` + +#### 5. Real implementation in `ChatController.swift` + +Next to the existing `scrollToMessageId:` argument in the `ChatControllerInteraction(...)` construction, add: + +```swift +scrollToMessageIdWithAnchor: { [weak self] index, anchor in + guard let self else { return } + var anchorY: CGFloat? + self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in + guard anchorY == nil else { return } + if let itemNode = itemNode as? ChatMessageBubbleItemNode, + itemNode.item?.message.id == index.id, + let rect = itemNode.getAnchorRect(anchor: anchor) { + anchorY = rect.minY + } + } + if let anchorY { + self.chatDisplayNode.historyNode.scrollToMessage( + from: index, to: index, + animated: true, highlight: false, + scrollPosition: .bottom(anchorY) + ) + } else { + self.chatDisplayNode.historyNode.scrollToMessage(index: index) + } +} +``` + +`ChatHistoryListNode.scrollToMessage(from:to:animated:highlight:quote:subject:scrollPosition:setupReply:)` already accepts `scrollPosition` and routes it through `MessageHistoryScrollToSubject` → `ListViewScrollToItem.position`. The `.bottom(additionalOffset)` formula sets `frame.maxY' = (visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (the anchor's item-local y in pre-transform coords), the chat list — rotated 180° at the layer — renders the anchor at the visual top of the content area. The `forEachVisibleItemNode` walk is safe because tapping the in-page anchor link requires the bubble to be at least partially visible. + +#### 6. Replace the `scrollToAnchor` stub + +In `ChatMessageRichDataBubbleContentNode.swift`: + +```swift +private func scrollToAnchor(_ anchor: String) { + guard let item = self.item else { return } + if anchor.isEmpty { + item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) + } else { + item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) + } +} +``` + +Empty anchor (the `#` with no fragment case) keeps the existing "scroll to message top" behavior. + +## Files touched + +| File | Change | +|---|---| +| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift` | Add `open func getAnchorRect(anchor:) -> CGRect?` returning `nil`. | +| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` | Override `getAnchorRect`; rewrite `scrollToAnchor` body. | +| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift` | Add public `getAnchorRect(anchor:)`. | +| `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` | New `scrollToMessageIdWithAnchor` field + init param + assignment. | +| `submodules/TelegramUI/Sources/ChatController.swift` | Real implementation of the closure. | +| 6 no-op stub sites | Add `scrollToMessageIdWithAnchor: { _, _ in }` next to existing stub. | + +## What is *not* changed + +- No new types in `Display/`, `AccountContext/`, or `TelegramCore/`. +- No changes to `MessageHistoryScrollToSubject` or `ChatHistoryLocation`. +- No changes to `InstantPageUI/` (the layout-walking logic is replicated in the rich-data bubble file rather than exported, since it's both small and specialized for the embedded layout). +- No changes to the existing `scrollToMessageId(_, CGFloat)` closure or its 7 call sites' signatures. + +## Verification + +There are no unit tests in this project. Verification is a full Bazel build: + +```sh +source ~/.zshrc 2>/dev/null; \ +python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent \ + --buildNumber=1 --configuration=debug_sim_arm64 +``` + +Manual smoke test in the simulator: open a chat that contains a webpage message rendered as a rich-data bubble with an instant page that has internal anchors (e.g., a Wikipedia article with section links or footnote references). Tap a section link or footnote link; the chat should scroll so that the target line lands at the top of the visible content area. diff --git a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift index bc84de9157..7b88fba51b 100644 --- a/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift @@ -83,6 +83,7 @@ public final class BrowserBookmarksScreen: ViewController { controller.openUrl(url.url) controller.dismiss() } + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -179,7 +180,8 @@ public final class BrowserBookmarksScreen: ViewController { }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index 933b8af958..996b15b381 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -401,6 +401,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.settings = InstantPagePresentationSettings( themeType: self.presentationData.theme.overallDarkAppearance ? .dark : .light, fontSize: fontSize, + lineSpacingFactor: 1.0, forceSerif: state.isSerif, autoNightMode: false, ignoreAutoNightModeUntil: 0 @@ -498,7 +499,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg return } - let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights, cachedMessageSyntaxHighlight: self.codeHighlight) + let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, sideInset: 17.0, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights, cachedMessageSyntaxHighlight: self.codeHighlight) let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: size.width) diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index a00cc9c112..05cd1827a1 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -2687,6 +2687,9 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur return false } let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() + if pinToEdgeTopInset <= 0.0 { + return false + } for itemNode in self.itemNodes { if itemNode.index == targetIndex { let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height) diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index 8df30b2e86..95f7b281d0 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -256,7 +256,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { updateLayout = true animated = true } - if previousSettings.fontSize != settings.fontSize || previousSettings.forceSerif != settings.forceSerif { + if previousSettings.fontSize != settings.fontSize || previousSettings.lineSpacingFactor != settings.lineSpacingFactor || previousSettings.forceSerif != settings.forceSerif { animated = false updateLayout = true } @@ -475,7 +475,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { return } - let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) + let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, sideInset: 17.0, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width) diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 39baa17a74..d511a57a4a 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -165,8 +165,7 @@ private func instantPageFirstTextLineMidY(in items: [InstantPageItem]) -> CGFloa return nil } -public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool) -> InstantPageLayout { - +public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: MediaResourceUserLocation, rtl: Bool, block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, safeInset: CGFloat, isCover: Bool, previousItems: [InstantPageItem], fillToSize: CGSize?, media: [EngineMedia.Id: EngineMedia], mediaIndexCounter: inout Int, embedIndexCounter: inout Int, detailsIndexCounter: inout Int, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, excludeCaptions: Bool, isLast: Bool) -> InstantPageLayout { let layoutCaption: (InstantPageCaption, CGSize) -> ([InstantPageItem], CGSize) = { caption, contentSize in var items: [InstantPageItem] = [] var offset = contentSize.height @@ -218,7 +217,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: switch block { case let .cover(block): - return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + return layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: true, previousItems:previousItems, fillToSize: fillToSize, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: true) case let .title(text): let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: theme, category: .header, link: false) @@ -355,9 +354,13 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: let (_, items, contentSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), media: media, webpage: webpage) return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case .divider: - let lineWidth = floor(boundingWidth / 2.0) - let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color) - return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem]) + if isLast { + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } else { + let lineWidth = floor(boundingWidth / 2.0) + let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0), size: CGSize(width: lineWidth, height: 1.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: lineWidth, height: 1.0)), shape: .rect, color: theme.textCategories.caption.color) + return InstantPageLayout(origin: CGPoint(), contentSize: shapeItem.frame.size, items: [shapeItem]) + } case let .list(contentItems, ordered): var contentSize = CGSize(width: boundingWidth, height: 0.0) var maxIndexWidth: CGFloat = 0.0 @@ -462,8 +465,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var previousBlock: InstantPageBlock? var originY: CGFloat = contentSize.height var firstBlockLineMidY: CGFloat? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock) : 0.0 let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing)) @@ -680,7 +684,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var i = 0 for subItem in innerItems { let frame = mosaicLayout[i].0 - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: true) + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subItem, boundingWidth: frame.width, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: frame.size, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: true, isLast: false) items.append(contentsOf: subLayout.flattenedItemsWithOrigin(frame.origin)) i += 1 } @@ -753,8 +757,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } var previousBlock: InstantPageBlock? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing)) @@ -936,8 +941,9 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var subDetailsIndex = 0 var previousBlock: InstantPageBlock? - for subBlock in blocks { - let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< blocks.count { + let subBlock = blocks[i] + let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock) let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) @@ -1007,10 +1013,12 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: contentSize.height += item.frame.height items.append(item) - let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0 - let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel) - let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor) - items.append(shapeItem) + if !isLast { + let inset: CGFloat = i == articles.count - 1 ? 0.0 : 17.0 + let lineSize = CGSize(width: boundingWidth - inset, height: UIScreenPixel) + let shapeItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(x: rtl || item.rtl ? 0.0 : inset, y: contentSize.height - lineSize.height), size: lineSize), shapeFrame: CGRect(origin: CGPoint(), size: lineSize), shape: .rect, color: theme.controlColor) + items.append(shapeItem) + } } return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) case let .map(latitude, longitude, zoom, dimensions, caption): @@ -1046,7 +1054,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } } -public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true) -> InstantPageLayout { +public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instantPage: InstantPage?, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, sideInset: CGFloat, safeInset: CGFloat, strings: PresentationStrings, theme: InstantPageTheme, dateTimeFormat: PresentationDateTimeFormat, webEmbedHeights: [Int : CGFloat] = [:], cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, addFeedback: Bool = true) -> InstantPageLayout { var maybeLoadedContent: TelegramMediaWebpageLoadedContent? if case let .Loaded(content) = webPage.content { maybeLoadedContent = content @@ -1074,8 +1082,9 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant var detailsIndexCounter: Int = 0 var previousBlock: InstantPageBlock? - for block in pageBlocks { - let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: 17.0 + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false) + for i in 0 ..< pageBlocks.count { + let block = pageBlocks[i] + let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1) let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block) let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) items.append(contentsOf: blockItems) diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index f3ac903e2e..c90d09e5b3 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -287,7 +287,7 @@ public final class InstantPageTextItem: InstantPageItem { context.setFillColor(color.cgColor) } let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0) - context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 1.0, width: itemFrame.size.width, height: 1.0)) + context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0)) } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift index 2ab0a10df8..8b49bbfcdd 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextStyleStack.swift @@ -197,6 +197,9 @@ final class InstantPageTextStyleStack { if let link = link, let linkColor = linkColor { attributes[NSAttributedString.Key.foregroundColor] = linkColor + if linkColor == color { + attributes[NSAttributedString.Key.underlineStyle] = NSUnderlineStyle.single.rawValue as NSNumber + } if link, let linkMarkerColor = linkMarkerColor { attributes[NSAttributedString.Key(rawValue: InstantPageMarkerColorAttribute)] = linkMarkerColor } diff --git a/submodules/InstantPageUI/Sources/InstantPageTheme.swift b/submodules/InstantPageUI/Sources/InstantPageTheme.swift index dccc23d9fd..72a6008861 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTheme.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTheme.swift @@ -4,23 +4,29 @@ import Display import TelegramPresentationData import TelegramUIPreferences -enum InstantPageFontStyle { +public enum InstantPageFontStyle { case sans case serif } -struct InstantPageFont { +public struct InstantPageFont { let style: InstantPageFontStyle let size: CGFloat let lineSpacingFactor: CGFloat + + public init(style: InstantPageFontStyle, size: CGFloat, lineSpacingFactor: CGFloat) { + self.style = style + self.size = size + self.lineSpacingFactor = lineSpacingFactor + } } -struct InstantPageTextAttributes { +public struct InstantPageTextAttributes { let font: InstantPageFont let color: UIColor let underline: Bool - init(font: InstantPageFont, color: UIColor, underline: Bool = false) { + public init(font: InstantPageFont, color: UIColor, underline: Bool = false) { self.font = font self.color = color self.underline = underline @@ -30,8 +36,8 @@ struct InstantPageTextAttributes { return InstantPageTextAttributes(font: self.font, color: self.color, underline: underline) } - func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextAttributes { - return InstantPageTextAttributes(font: InstantPageFont(style: forceSerif ? .serif : self.font.style, size: floor(self.font.size * sizeMultiplier), lineSpacingFactor: self.font.lineSpacingFactor), color: self.color, underline: self.underline) + func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTextAttributes { + return InstantPageTextAttributes(font: InstantPageFont(style: forceSerif ? .serif : self.font.style, size: floor(self.font.size * sizeMultiplier), lineSpacingFactor: self.font.lineSpacingFactor * lineSpacingFactor), color: self.color, underline: self.underline) } } @@ -56,6 +62,17 @@ public struct InstantPageTextCategories { let table: InstantPageTextAttributes let article: InstantPageTextAttributes + public init(kicker: InstantPageTextAttributes, header: InstantPageTextAttributes, subheader: InstantPageTextAttributes, paragraph: InstantPageTextAttributes, caption: InstantPageTextAttributes, credit: InstantPageTextAttributes, table: InstantPageTextAttributes, article: InstantPageTextAttributes) { + self.kicker = kicker + self.header = header + self.subheader = subheader + self.paragraph = paragraph + self.caption = caption + self.credit = credit + self.table = table + self.article = article + } + func attributes(type: InstantPageTextCategoryType, link: Bool) -> InstantPageTextAttributes { switch type { case .kicker: @@ -77,8 +94,17 @@ public struct InstantPageTextCategories { } } - func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTextCategories { - return InstantPageTextCategories(kicker: self.kicker.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), header: self.header.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), subheader: self.subheader.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), paragraph: self.paragraph.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif)) + func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTextCategories { + return InstantPageTextCategories( + kicker: self.kicker.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + header: self.header.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + subheader: self.subheader.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + paragraph: self.paragraph.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + caption: self.caption.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + credit: self.credit.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + table: self.table.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), + article: self.article.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif) + ) } } @@ -132,8 +158,8 @@ public final class InstantPageTheme { self.overlayPanelColor = overlayPanelColor } - public func withUpdatedFontStyles(sizeMultiplier: CGFloat, forceSerif: Bool) -> InstantPageTheme { - return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor) + public func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTheme { + return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor) } func headingTextAttributes(level: Int32, link: Bool) -> InstantPageTextAttributes { @@ -339,14 +365,14 @@ func instantPageThemeTypeForSettingsAndTime(themeSettings: PresentationThemeSett public func instantPageThemeForType(_ type: InstantPageThemeType, settings: InstantPagePresentationSettings) -> InstantPageTheme { switch type { - case .light: - return lightTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .sepia: - return sepiaTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .gray: - return grayTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) - case .dark: - return darkTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), forceSerif: settings.forceSerif) + case .light: + return lightTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .sepia: + return sepiaTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .gray: + return grayTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) + case .dark: + return darkTheme.withUpdatedFontStyles(sizeMultiplier: fontSizeMultiplierForVariant(settings.fontSize), lineSpacingFactor: settings.lineSpacingFactor, forceSerif: settings.forceSerif) } } diff --git a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift index 39aec40872..abb6a5e6db 100644 --- a/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift +++ b/submodules/TelegramUI/Components/Chat/ChatBotInfoItem/Sources/ChatBotInfoItem.swift @@ -185,7 +185,7 @@ public final class ChatBotInfoItemNode: ListViewItemNode { break case .ignore: return .fail - case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom: + case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom, .externalInstantPage: return .waitForSingleTap } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift index 0e849d382e..539164a44f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift @@ -162,6 +162,7 @@ public struct ChatMessageBubbleContentTapAction { case date(Int32, String) case largeEmoji(String, String?, TelegramMediaFile) case customEmoji(TelegramMediaFile) + case externalInstantPage(url: Url, webpageId: MediaId, anchor: String?) case custom(() -> Void) } @@ -260,7 +261,11 @@ open class ChatMessageBubbleContentNode: ASDisplayNode { open func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil } - + + open func getAnchorRect(anchor: String) -> CGRect? { + return nil + } + open func updateHiddenMedia(_ media: [Media]?) -> Bool { return false } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 2195dfb30a..4a117bf8a0 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1347,7 +1347,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI break case .ignore: return .fail - case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom: + case .url, .phone, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .wallpaper, .theme, .call, .conferenceCall, .openMessage, .timecode, .bankCard, .tooltip, .openPollResults, .copy, .largeEmoji, .customEmoji, .date, .custom, .externalInstantPage: return .waitForSingleTap } } @@ -5791,6 +5791,27 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: url.url, concealed: url.concealed, message: item.content.firstMessage, allowInlineWebpageResolution: url.allowInlineWebpageResolution, progress: tapAction.activate?())) }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) } + case let .externalInstantPage(url, webpageId, anchor): + if case .longTap = gesture, !tapAction.hasLongTapAction, let item = self.item { + let tapMessage = item.content.firstMessage + var subFrame = self.backgroundNode.frame + if case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == tapMessage.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } + } + } + return .openContextMenu(InternalBubbleTapAction.OpenContextMenu(tapMessage: tapMessage, selectAll: false, subFrame: subFrame, disableDefaultPressAnimation: true)) + } else { + return .action(InternalBubbleTapAction.Action({ [weak self] in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.openExternalInstantPage(ChatControllerInteraction.OpenInstantPage(webpageId: webpageId, url: url.url, anchor: anchor, concealed: true, progress: tapAction.activate?())) + }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) + } case let .phone(number): return .action(InternalBubbleTapAction.Action({ [weak self] in guard let self, let item = self.item, let contentNode = self.contextContentNodeForLink(number, rects: rects) else { @@ -5999,6 +6020,42 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } else { disableDefaultPressAnimation = true } + case let .externalInstantPage(url, webpageId, anchor): + if tapAction.hasLongTapAction { + return .action(InternalBubbleTapAction.Action({}, actionWithLongTapRecognizer: { [weak self] gesture in + guard let self, let item = self.item else { + return + } + let cleanUrl = url.url.replacingOccurrences(of: "mailto:", with: "") + guard let contentNode = self.contextContentNodeForLink(cleanUrl, rects: rects) else { + return + } + item.controllerInteraction.longTap(.url(url.url), ChatControllerInteraction.LongTapParams(message: item.content.firstMessage, contentNode: contentNode, messageNode: self, progress: tapAction.activate?(), gesture: gesture)) + }, contextMenuOnLongPress: false)) + } else { + disableDefaultPressAnimation = true + } + + if case .longTap = gesture, !tapAction.hasLongTapAction, let item = self.item { + let tapMessage = item.content.firstMessage + var subFrame = self.backgroundNode.frame + if case .group = item.content { + for contentNode in self.contentNodes { + if contentNode.item?.message.stableId == tapMessage.stableId { + subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) + break + } + } + } + return .openContextMenu(InternalBubbleTapAction.OpenContextMenu(tapMessage: tapMessage, selectAll: false, subFrame: subFrame, disableDefaultPressAnimation: true)) + } else { + return .action(InternalBubbleTapAction.Action({ [weak self] in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.openExternalInstantPage(ChatControllerInteraction.OpenInstantPage(webpageId: webpageId, url: url.url, anchor: anchor, concealed: true, progress: tapAction.activate?())) + }, contextMenuOnLongPress: !tapAction.hasLongTapAction)) + } case let .phone(number): if tapAction.hasLongTapAction { return .action(InternalBubbleTapAction.Action({}, actionWithLongTapRecognizer: { [weak self] gesture in @@ -7187,7 +7244,16 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } return nil } - + + public func getAnchorRect(anchor: String) -> CGRect? { + for contentNode in self.contentNodes { + if let result = contentNode.getAnchorRect(anchor: anchor) { + return contentNode.view.convert(result, to: self.view) + } + } + return nil + } + public func getInnerReplySubjectRect(innerSubject: EngineMessageReplyInnerSubject) -> CGRect? { switch innerSubject { case let .todoItem(todoItemId): diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index dc033a5580..f09fc1549f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -22,6 +22,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var visibleTiles: [Int: InstantPageTileNode] = [:] private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? + private var pageTheme: InstantPageTheme? private var distanceThresholdGroupCount: [Int: Int] = [:] private var currentLayoutItemsWithNodes: [InstantPageItem] = [] private var currentExpandedDetails: [Int : Bool]? @@ -56,6 +57,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let previousItem = self.item let currentPageLayout = self.currentPageLayout let previousCurrentLayoutTiles = self.currentLayoutTiles @@ -71,20 +73,117 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode var pageLayout: InstantPageLayout? var currentLayoutTiles: [InstantPageTile] = [] + let isDark = item.presentationData.theme.theme.overallDarkAppearance + let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId) + let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing + + var underlineLinks = true + if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) { + underlineLinks = false + } + let _ = underlineLinks + + let author = item.message.author + let mainColor: UIColor + var secondaryColor: UIColor? = nil + var tertiaryColor: UIColor? = nil + + let nameColors: PeerNameColors.Colors? + switch author?.nameColor { + case let .preset(nameColor): + nameColors = item.context.peerNameColors.get(nameColor, dark: item.presentationData.theme.theme.overallDarkAppearance) + case let .collectible(collectibleColor): + nameColors = collectibleColor.peerNameColors(dark: item.presentationData.theme.theme.overallDarkAppearance) + default: + nameColors = nil + } + + let codeBlockTitleColor: UIColor + let codeBlockAccentColor: UIColor + let codeBlockBackgroundColor: UIColor + if !isIncoming { + mainColor = messageTheme.accentTextColor + if let _ = nameColors?.secondary { + secondaryColor = .clear + } + if let _ = nameColors?.tertiary { + tertiaryColor = .clear + } + + if item.presentationData.theme.theme.overallDarkAppearance { + codeBlockTitleColor = .white + codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5) + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25) + } else { + codeBlockTitleColor = mainColor + codeBlockAccentColor = mainColor + codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1) + } + } else { + let authorNameColor = nameColors?.main + secondaryColor = nameColors?.secondary + tertiaryColor = nameColors?.tertiary + + if let authorNameColor { + mainColor = authorNameColor + } else { + mainColor = messageTheme.accentTextColor + } + + codeBlockTitleColor = mainColor + codeBlockAccentColor = mainColor + + if item.presentationData.theme.theme.overallDarkAppearance { + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.65) + } else { + codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.05) + } + } + + let _ = secondaryColor + let _ = tertiaryColor + + let _ = codeBlockTitleColor + let _ = codeBlockAccentColor + + let textCategories = InstantPageTextCategories( + kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor), + paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor), + table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor), + article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor) + ) + let pageTheme = InstantPageTheme( + type: isDark ? .dark : .light, + pageBackgroundColor: .clear, + textCategories: textCategories, + serif: false, + codeBlockBackgroundColor: codeBlockBackgroundColor, + linkColor: messageTheme.linkTextColor, + textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1), + linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1), + markerColor: UIColor(rgb: 0xfef3bc), + panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1), + panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25), + panelPrimaryColor: messageTheme.primaryTextColor, + panelSecondaryColor: messageTheme.secondaryTextColor, + panelAccentColor: messageTheme.accentTextColor, + tableBorderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.25) : UIColor(white: 0.0, alpha: 0.1), + tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05), + controlColor: messageTheme.accentControlColor, + imageTintColor: nil, + overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13) + ) + if let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, let instantPage = content.instantPage { - if let current = currentPageLayout, current.boundingWidth == boundingSize.width { + if let current = currentPageLayout, current.boundingWidth == boundingSize.width, previousItem?.presentationData.theme.theme === item.presentationData.theme.theme { pageLayout = current.layout currentLayoutTiles = previousCurrentLayoutTiles } else { - let pageTheme = instantPageThemeForType(item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( - themeType: item.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, - fontSize: .standard, - lineSpacingFactor: 0.9, - forceSerif: false, - autoNightMode: false, - ignoreAutoNightModeUntil: 0 - )) - pageLayout = instantPageLayoutForWebPage(webpage, instantPage: instantPage._parse(), userLocation: .other, boundingWidth: boundingWidth - 2.0, safeInset: 0.0, strings: item.presentationData.strings, theme: pageTheme, dateTimeFormat: item.presentationData.dateTimeFormat, webEmbedHeights: [:], addFeedback: false) + pageLayout = instantPageLayoutForWebPage(webpage, instantPage: instantPage._parse(), userLocation: .other, boundingWidth: boundingWidth - 2.0, sideInset: 10.0, safeInset: 0.0, strings: item.presentationData.strings, theme: pageTheme, dateTimeFormat: item.presentationData.dateTimeFormat, webEmbedHeights: [:], addFeedback: false) if let pageLayout { currentLayoutTiles = instantPageTilesFromLayout(pageLayout, boundingWidth: boundingWidth) } @@ -100,6 +199,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return } self.item = item + self.pageTheme = pageTheme self.containerNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingSize.width - 2.0, height: boundingSize.height - 2.0)) @@ -158,17 +258,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } private func updateVisibleItems(visibleBounds: CGRect, animated: Bool = false) { - guard let messageItem = self.item else { + guard let messageItem = self.item, let pageTheme = self.pageTheme else { return } - let pageTheme = instantPageThemeForType(messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, settings: InstantPagePresentationSettings( - themeType: messageItem.presentationData.theme.theme.overallDarkAppearance ? .dark : .light, - fontSize: .standard, - lineSpacingFactor: 0.9, - forceSerif: false, - autoNightMode: false, - ignoreAutoNightModeUntil: 0 - )) let sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .otherPrivate) var visibleTileIndices = Set() @@ -427,13 +519,23 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode // The chat URL handler will show a confirmation when concealed is true // and the visible text differs from the destination — safer default. let concealed = true - let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) + let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed, allowInlineWebpageResolution: urlHit.urlItem.webpageId != nil) let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - return ChatMessageBubbleContentTapAction( - content: .url(url), - rects: rects, - activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - ) + + if let webpageId = urlHit.urlItem.webpageId { + let split = self.splitAnchor(url.url) + return ChatMessageBubbleContentTapAction( + content: .externalInstantPage(url: url, webpageId: webpageId, anchor: split.anchor), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } else { + return ChatMessageBubbleContentTapAction( + content: .url(url), + rects: rects, + activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) + ) + } } private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? { @@ -650,6 +752,42 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode return nil } + override public func getAnchorRect(anchor: String) -> CGRect? { + guard let layout = self.currentPageLayout?.layout else { + return nil + } + if let rect = self.anchorRect(in: layout.items, anchor: anchor, baseY: 0.0) { + // Translate from layout/containerNode coords to bubble-content-node coords. + // containerNode is offset by (1, 1) from the bubble content node. + return rect.offsetBy(dx: 1.0, dy: 1.0) + } + return nil + } + + private func anchorRect(in items: [InstantPageItem], anchor: String, baseY: CGFloat) -> CGRect? { + for item in items { + if let item = item as? InstantPageAnchorItem, item.anchor == anchor { + return CGRect(x: item.frame.minX, y: baseY + item.frame.minY, width: 1.0, height: 1.0) + } else if let item = item as? InstantPageTextItem { + if let (lineIndex, _) = item.anchors[anchor] { + let lineFrame = item.lines[lineIndex].frame + return CGRect(x: item.frame.minX + lineFrame.minX, y: baseY + item.frame.minY + lineFrame.minY, width: lineFrame.width, height: lineFrame.height) + } + } else if let item = item as? InstantPageTableItem { + if let (offset, _) = item.anchors[anchor] { + return CGRect(x: item.frame.minX, y: baseY + item.frame.minY + offset, width: item.frame.width, height: 1.0) + } + } else if let item = item as? InstantPageDetailsItem { + // Inner items are laid out below the title bar, so the recursive base + // must include titleHeight (mirrors InstantPageDetailsNode.linkSelectionRects). + if let rect = self.anchorRect(in: item.items, anchor: anchor, baseY: baseY + item.frame.minY + item.titleHeight) { + return rect + } + } + } + return nil + } + override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { /*if let statusNode = self.statusNode, !statusNode.isHidden { return statusNode.reactionView(value: value) @@ -692,7 +830,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } private func scrollToAnchor(_ anchor: String) { - // TODO: implement intra-page anchor scrolling - let _ = anchor + guard let item = self.item else { + return + } + if anchor.isEmpty { + item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) + } else { + item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) + } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index a8797855fe..d11768efe9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -832,8 +832,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } strongSelf.textNode.textNode.displaysAsynchronously = !item.presentationData.isPreview - animation.animator.updateFrame(layer: strongSelf.containerNode.layer, frame: CGRect(origin: CGPoint(), size: boundingSize), completion: nil) - + animation.animator.updatePosition(layer: strongSelf.containerNode.layer, position: CGRect(origin: CGPoint(), size: boundingSize).center, completion: nil) + animation.animator.updateBounds(layer: strongSelf.containerNode.layer, bounds: CGRect(origin: CGPoint(), size: boundingSize), completion: nil) if let formattedDateUpdatePeriod { if strongSelf.relativeDateTimer?.period != formattedDateUpdatePeriod { @@ -917,7 +917,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } ) )) - animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: realTextFrame, completion: nil) + animation.animator.updatePosition(layer: strongSelf.textNode.textNode.layer, position: realTextFrame.center, completion: nil) + animation.animator.updateBounds(layer: strongSelf.textNode.textNode.layer, bounds: CGRect(origin: CGPoint(), size: realTextFrame.size), completion: nil) switch strongSelf.visibility { case .none: @@ -971,7 +972,8 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { } statusNode.frame = statusFrame } else { - animation.animator.updateFrame(layer: statusNode.layer, frame: statusFrame, completion: nil) + animation.animator.updatePosition(layer: statusNode.layer, position: statusFrame.center, completion: nil) + animation.animator.updateBounds(layer: statusNode.layer, bounds: CGRect(origin: CGPoint(), size: statusFrame.size), completion: nil) } } else if let statusNode = strongSelf.statusNode { strongSelf.statusNode = nil diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift index e1408ee47d..40d13fac3e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift @@ -327,6 +327,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { } }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { [weak self] url in self?.openUrl(url.url, progress: url.progress) + }, openExternalInstantPage: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { [weak self] message, associatedData in if let strongSelf = self, let navigationController = strongSelf.getNavigationController() { if let controller = strongSelf.context.sharedContext.makeInstantPageController(context: strongSelf.context, message: message, sourcePeerType: associatedData?.automaticDownloadPeerType) { @@ -656,7 +657,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index f5069b7768..85ac8110b9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -426,7 +426,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, toggleMessagesSelection: { _, _ in }, sendCurrentMessage: { _, _ in }, sendMessage: { _ in }, sendSticker: { _, _, _, _, _, _, _, _, _ in return false }, sendEmoji: { _, _, _ in }, sendGif: { _, _, _, _, _ in return false }, sendBotContextResultAsGif: { _, _, _, _, _, _ in return false }, editGif: { _, _ in - }, requestMessageActionCallback: { _, _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, updatePresentationState: { _ in }, openMessageShareMenu: { _ in + }, requestMessageActionCallback: { _, _, _, _, _ in }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, openExternalInstantPage: { _ in + }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _, _ in }, openWallpaper: { _ in }, openTheme: { _ in }, openHashtag: { _, _ in }, updateInputState: { _ in }, updateInputMode: { _ in }, updatePresentationState: { _ in }, openMessageShareMenu: { _ in }, presentController: { _, _ in }, presentControllerInCurrent: { _, _ in }, navigationController: { @@ -503,7 +504,8 @@ public final class ChatSendGroupMediaMessageContextPreview: UIView, ChatSendMess }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift index 168605725f..43ba16297c 100644 --- a/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift @@ -158,6 +158,22 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol } } + public struct OpenInstantPage { + public var webpageId: MediaId + public var url: String + public var anchor: String? + public var concealed: Bool + public var progress: Promise? + + public init(webpageId: MediaId, url: String, anchor: String?, concealed: Bool, progress: Promise? = nil) { + self.webpageId = webpageId + self.url = url + self.anchor = anchor + self.concealed = concealed + self.progress = progress + } + } + public struct LongTapParams { public var message: Message? public var contentNode: ContextExtractedContentContainingNode? @@ -204,6 +220,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let requestMessageActionUrlAuth: (String, MessageActionUrlSubject) -> Void public let activateSwitchInline: (PeerId?, String, ReplyMarkupButtonAction.PeerTypes?) -> Void public let openUrl: (OpenUrl) -> Void + public let openExternalInstantPage: (OpenInstantPage) -> Void public let shareCurrentLocation: () -> Void public let shareAccountContact: () -> Void public let sendBotCommand: (MessageId?, String) -> Void @@ -291,7 +308,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol public let requestMessageUpdate: (MessageId, Bool, ControlledTransition?) -> Void public let cancelInteractiveKeyboardGestures: () -> Void public let dismissTextInput: () -> Void - public let scrollToMessageId: (MessageIndex) -> Void + public let scrollToMessageId: (MessageIndex, CGFloat) -> Void + public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void public let navigateToStory: (Message, StoryId) -> Void public let attemptedNavigationToPrivateQuote: (Peer?) -> Void public let forceUpdateWarpContents: () -> Void @@ -382,6 +400,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol requestMessageActionUrlAuth: @escaping (String, MessageActionUrlSubject) -> Void, activateSwitchInline: @escaping (PeerId?, String, ReplyMarkupButtonAction.PeerTypes?) -> Void, openUrl: @escaping (OpenUrl) -> Void, + openExternalInstantPage: @escaping (OpenInstantPage) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId?, String) -> Void, @@ -469,7 +488,8 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol requestMessageUpdate: @escaping (MessageId, Bool, ControlledTransition?) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, dismissTextInput: @escaping () -> Void, - scrollToMessageId: @escaping (MessageIndex) -> Void, + scrollToMessageId: @escaping (MessageIndex, CGFloat) -> Void, + scrollToMessageIdWithAnchor: @escaping (MessageIndex, String) -> Void, navigateToStory: @escaping (Message, StoryId) -> Void, attemptedNavigationToPrivateQuote: @escaping (Peer?) -> Void, forceUpdateWarpContents: @escaping () -> Void, @@ -512,6 +532,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.requestMessageActionUrlAuth = requestMessageActionUrlAuth self.activateSwitchInline = activateSwitchInline self.openUrl = openUrl + self.openExternalInstantPage = openExternalInstantPage self.shareCurrentLocation = shareCurrentLocation self.shareAccountContact = shareAccountContact self.sendBotCommand = sendBotCommand @@ -601,6 +622,7 @@ public final class ChatControllerInteraction: ChatControllerInteractionProtocol self.cancelInteractiveKeyboardGestures = cancelInteractiveKeyboardGestures self.dismissTextInput = dismissTextInput self.scrollToMessageId = scrollToMessageId + self.scrollToMessageIdWithAnchor = scrollToMessageIdWithAnchor self.navigateToStory = navigateToStory self.attemptedNavigationToPrivateQuote = attemptedNavigationToPrivateQuote self.forceUpdateWarpContents = forceUpdateWarpContents diff --git a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift index 25153b3fe8..af0f34a9a1 100644 --- a/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift +++ b/submodules/TelegramUI/Components/InteractiveTextComponent/Sources/InteractiveTextComponent.swift @@ -3246,8 +3246,11 @@ final class TextContentItemLayer: SimpleLayer { } } - animation.animator.updateFrame(layer: self.renderNodeContainer, frame: effectiveContentFrame, completion: nil) - animation.animator.updateFrame(layer: self.renderNode.layer, frame: CGRect(origin: CGPoint(), size: effectiveContentFrame.size), completion: nil) + animation.animator.updatePosition(layer: self.renderNodeContainer, position: effectiveContentFrame.center, completion: nil) + animation.animator.updateBounds(layer: self.renderNodeContainer, bounds: CGRect(origin: CGPoint(), size: effectiveContentFrame.size), completion: nil) + + animation.animator.updatePosition(layer: self.renderNode.layer, position: effectiveContentFrame.center, completion: nil) + animation.animator.updateBounds(layer: self.renderNode.layer, bounds: CGRect(origin: CGRect(origin: CGPoint(), size: effectiveContentFrame.size).center, size: effectiveContentFrame.size), completion: nil) var staticContentMask = contentMask if let contentMask, self.isAnimating { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 6c61401182..43b382ee37 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -1109,6 +1109,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro return } strongSelf.openUrl(url: url.url, concealed: url.concealed, external: url.external ?? false) + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -1274,7 +1275,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 68457c5b26..fb0c1a7311 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3221,6 +3221,66 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } else { strongSelf.openUrl(url, concealed: concealed, forceExternal: forceExternal, skipConcealedAlert: skipConcealedAlert, message: message, allowInlineWebpageResolution: urlData.allowInlineWebpageResolution, progress: progress) } + }, openExternalInstantPage: { [weak self] instantPage in + guard let self else { + return + } + let _ = (webpagePreviewWithProgress(account: self.context.account, urls: [instantPage.url], webpageId: instantPage.webpageId) + |> deliverOnMainQueue).startStandalone(next: { [weak self] result in + Task { @MainActor in + guard let self else { + return + } + switch result { + case let .result(webpageResult): + instantPage.progress?.set(.single(false)) + + guard let webpageResult else { + self.openUrl(instantPage.url, concealed: true) + return + } + + if case .Loaded = webpageResult.webpage.content { + let sourceLocation: InstantPageSourceLocation + + if let peerId = self.chatLocation.peerId { + let (peer, isContact) = await self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Peer.IsContact(id: peerId) + ).get() + + if let peer { + let peerType: MediaAutoDownloadPeerType + switch peer { + case .user: + peerType = isContact ? .contact : .otherPrivate + case let .channel(channel): + if case .group = channel.info { + peerType = .group + } else { + peerType = .channel + } + case .legacyGroup: + peerType = .group + case .secretChat: + return + } + let peerLocation = MediaResourceUserLocation.peer(peer.id) + sourceLocation = InstantPageSourceLocation(userLocation: peerLocation, peerType: peerType) + } else { + sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .channel) + } + } else { + sourceLocation = InstantPageSourceLocation(userLocation: .other, peerType: .channel) + } + + self.push(makeInstantPageControllerImpl(context: self.context, webPage: webpageResult.webpage, anchor: instantPage.anchor, sourceLocation: sourceLocation)) + } + case .progress: + instantPage.progress?.set(.single(true)) + } + } + }) }, shareCurrentLocation: { [weak self] in if let strongSelf = self { if case .pinnedMessages = strongSelf.presentationInterfaceState.subject { @@ -5334,8 +5394,38 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, dismissTextInput: { [weak self] in self?.chatDisplayNode.dismissTextInput() - }, scrollToMessageId: { [weak self] index in - self?.chatDisplayNode.historyNode.scrollToMessage(index: index) + }, scrollToMessageId: { [weak self] index, offset in + self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset) + }, scrollToMessageIdWithAnchor: { [weak self] index, anchor in + guard let self else { + return + } + // Find the bubble for this message (it must be at least partially + // visible — we got here from a tap inside it) and compute the + // anchor's y in item-local coords. .bottom(anchorY) places the item + // so the anchor lands at the visual top of the rotated chat list's + // content area; .center(.custom) is bypassed for short items, so it + // can't position the anchor uniformly. + var anchorY: CGFloat? + self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in + guard anchorY == nil else { + return + } + if let itemNode = itemNode as? ChatMessageBubbleItemNode, + itemNode.item?.message.id == index.id, + let rect = itemNode.getAnchorRect(anchor: anchor) { + anchorY = rect.minY + } + } + if let anchorY { + self.chatDisplayNode.historyNode.scrollToMessage( + from: index, to: index, + animated: true, highlight: false, + scrollPosition: .bottom(anchorY) + ) + } else { + self.chatDisplayNode.historyNode.scrollToMessage(index: index) + } }, navigateToStory: { [weak self] message, storyId in guard let self else { return diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index 2ee2daf1e4..2302845a6f 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -5064,7 +5064,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH } } - func scrollToMessage(index: MessageIndex) { + func scrollToMessage(index: MessageIndex, offset: CGFloat = 0.0) { self.appliedScrollToMessageId = nil self.scrollToMessageIdPromise.set(.single(index)) } diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index 6d916852c5..d2b59d5a02 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -478,7 +478,7 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { return false } -func makeInstantPageControllerImpl(context: AccountContext, message: Message, sourcePeerType: MediaAutoDownloadPeerType?) -> ViewController? { +public func makeInstantPageControllerImpl(context: AccountContext, message: Message, sourcePeerType: MediaAutoDownloadPeerType?) -> ViewController? { guard let (webpage, anchor) = instantPageAndAnchor(message: message) else { return nil } @@ -486,7 +486,7 @@ func makeInstantPageControllerImpl(context: AccountContext, message: Message, so return makeInstantPageControllerImpl(context: context, webPage: webpage, anchor: anchor, sourceLocation: sourceLocation) } -func makeInstantPageControllerImpl(context: AccountContext, webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) -> ViewController { +public func makeInstantPageControllerImpl(context: AccountContext, webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation) -> ViewController { return BrowserScreen(context: context, subject: .instantPage(webPage: webPage, anchor: anchor, sourceLocation: sourceLocation, preloadedResources: nil)) } diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index 9fa74bbce6..213aa143fe 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -155,6 +155,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in + }, openExternalInstantPage: { _ in }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in @@ -248,7 +249,8 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, ASGestu }, requestMessageUpdate: { _, _, _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { - }, scrollToMessageId: { _ in + }, scrollToMessageId: { _, _ in + }, scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, attemptedNavigationToPrivateQuote: { _ in }, forceUpdateWarpContents: { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 223c0af0a8..3896988f1f 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -2392,6 +2392,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { requestMessageActionUrlAuth: { _, _ in }, activateSwitchInline: { _, _, _ in }, openUrl: { _ in }, + openExternalInstantPage: { _ in + }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, @@ -2560,7 +2562,9 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, dismissTextInput: { }, - scrollToMessageId: { _ in + scrollToMessageId: { _, _ in + }, + scrollToMessageIdWithAnchor: { _, _ in }, navigateToStory: { _, _ in }, diff --git a/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift b/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift index 420dca7e11..8781c437ec 100644 --- a/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/InstantPagePresentationSettings.swift @@ -21,17 +21,19 @@ public enum InstantPagePresentationFontSize: Int32 { } public final class InstantPagePresentationSettings: Codable, Equatable { - public static var defaultSettings = InstantPagePresentationSettings(themeType: .light, fontSize: .standard, forceSerif: false, autoNightMode: true, ignoreAutoNightModeUntil: 0) + public static var defaultSettings = InstantPagePresentationSettings(themeType: .light, fontSize: .standard, lineSpacingFactor: 1.0, forceSerif: false, autoNightMode: true, ignoreAutoNightModeUntil: 0) public var themeType: InstantPageThemeType public var fontSize: InstantPagePresentationFontSize + public var lineSpacingFactor: CGFloat public var forceSerif: Bool public var autoNightMode: Bool public var ignoreAutoNightModeUntil: Int32 - public init(themeType: InstantPageThemeType, fontSize: InstantPagePresentationFontSize, forceSerif: Bool, autoNightMode: Bool, ignoreAutoNightModeUntil: Int32) { + public init(themeType: InstantPageThemeType, fontSize: InstantPagePresentationFontSize, lineSpacingFactor: CGFloat, forceSerif: Bool, autoNightMode: Bool, ignoreAutoNightModeUntil: Int32) { self.themeType = themeType self.fontSize = fontSize + self.lineSpacingFactor = lineSpacingFactor self.forceSerif = forceSerif self.autoNightMode = autoNightMode self.ignoreAutoNightModeUntil = ignoreAutoNightModeUntil @@ -42,6 +44,7 @@ public final class InstantPagePresentationSettings: Codable, Equatable { self.themeType = InstantPageThemeType(rawValue: try container.decode(Int32.self, forKey: "themeType"))! self.fontSize = InstantPagePresentationFontSize(rawValue: try container.decode(Int32.self, forKey: "fontSize"))! + self.lineSpacingFactor = try container.decodeIfPresent(Double.self, forKey: "lineSpacingFactor") ?? 1.0 self.forceSerif = try container.decode(Int32.self, forKey: "forceSerif") != 0 self.autoNightMode = try container.decode(Int32.self, forKey: "autoNightMode") != 0 self.ignoreAutoNightModeUntil = try container.decode(Int32.self, forKey: "ignoreAutoNightModeUntil") @@ -52,6 +55,7 @@ public final class InstantPagePresentationSettings: Codable, Equatable { try container.encode(self.themeType.rawValue, forKey: "themeType") try container.encode(self.fontSize.rawValue, forKey: "fontSize") + try container.encode(self.lineSpacingFactor, forKey: "lineSpacingFactor") try container.encode((self.forceSerif ? 1 : 0) as Int32, forKey: "forceSerif") try container.encode((self.autoNightMode ? 1 : 0) as Int32, forKey: "autoNightMode") try container.encode(self.ignoreAutoNightModeUntil, forKey: "ignoreAutoNightModeUntil") @@ -64,6 +68,9 @@ public final class InstantPagePresentationSettings: Codable, Equatable { if lhs.fontSize != rhs.fontSize { return false } + if lhs.lineSpacingFactor != rhs.lineSpacingFactor { + return false + } if lhs.forceSerif != rhs.forceSerif { return false } @@ -77,23 +84,23 @@ public final class InstantPagePresentationSettings: Codable, Equatable { } public func withUpdatedThemeType(_ themeType: InstantPageThemeType) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedFontSize(_ fontSize: InstantPagePresentationFontSize) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: fontSize, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedForceSerif(_ forceSerif: Bool) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: forceSerif, autoNightMode: self.autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedAutoNightMode(_ autoNightMode: Bool) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: self.ignoreAutoNightModeUntil) } public func withUpdatedIgnoreAutoNightModeUntil(_ ignoreAutoNightModeUntil: Int32) -> InstantPagePresentationSettings { - return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: ignoreAutoNightModeUntil) + return InstantPagePresentationSettings(themeType: self.themeType, fontSize: self.fontSize, lineSpacingFactor: self.lineSpacingFactor, forceSerif: self.forceSerif, autoNightMode: autoNightMode, ignoreAutoNightModeUntil: ignoreAutoNightModeUntil) } } From 972cdf06583467bfa51e9a161b5b4cd2a64545dc Mon Sep 17 00:00:00 2001 From: isaac <> Date: Fri, 1 May 2026 21:06:25 +0200 Subject: [PATCH 66/69] Rich bubble: text selection in context-preview mode Adds drag-handle text selection driven by TextSelectionNode. Exposes attributedString and selection helpers on InstantPage text items, and introduces a multi-text adapter aggregating items as a TextNodeProtocol. Gates selection actions on reply-options and fixes highlight z-order. --- .../2026-05-01-rich-bubble-text-selection.md | 627 ++++++++++++++++++ ...05-01-rich-bubble-text-selection-design.md | 145 ++++ .../Sources/InstantPageMultiTextAdapter.swift | 103 +++ .../Sources/InstantPageTextItem.swift | 77 ++- .../BUILD | 1 + ...ChatMessageRichDataBubbleContentNode.swift | 80 ++- 6 files changed, 1020 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md create mode 100644 docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md create mode 100644 submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift diff --git a/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md b/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md new file mode 100644 index 0000000000..af23d2627f --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md @@ -0,0 +1,627 @@ +# Rich-bubble text selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all visible `InstantPageTextItem`s. + +**Architecture:** Extend `InstantPageTextItem` with a small public surface (`attributedString` + `attributesAtPoint(_:orNearest:)` + `textRangeRects(in:)`). Add a new `InstantPageMultiTextAdapter` that implements `TextNodeProtocol` by aggregating multiple items into one character-indexed view. In the rich-bubble, gate selection on `updateIsExtractedToContextPreview`, mirroring `ChatMessageTextBubbleContentNode`. + +**Tech Stack:** Swift, AsyncDisplayKit, Bazel (`build-system/Make/Make.py`); modules `Display` (TextNodeProtocol, TextRangeRectEdge), `InstantPageUI` (text item + new adapter), `TextSelectionNode`, `ChatControllerInteraction`. + +**Reference spec:** `docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md` + +**Project context:** No automated tests (per `CLAUDE.md`). Per-task verification is "Bazel build green" using: + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache \ + build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ + --continueOnError +``` + +The final task is a manual smoke test in the simulator. + +**Working-tree note:** The user has unrelated WIP modifications in the tree (notably `submodules/InstantPageUI/Sources/InstantPageLayout.swift`, `InstantPageControllerNode.swift`, `InstantPageTheme.swift`, plus a `lineSpacingFactor: 0.9` addition in the rich-bubble). DO NOT touch those files except for the specific edits this plan calls out. Use `git add ` — never `git add -A` / `git add .`. + +--- + +## File map + +- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote `attributedString` to public; add a new public `attributesAtPoint(_:orNearest:)` extending the existing internal one; add a new public `textRangeRects(in:)` returning `Display.TextRangeRectEdge`. +- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — new file containing `InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol`. No BUILD changes needed; the file is picked up by `glob(["Sources/**/*.swift"])` and `Display` is already a dep of `InstantPageUI`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode` to `deps`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add `import TextSelectionNode`, two new ivars, and the lifecycle hooks. + +No other files change. + +--- + +### Task 1: Public surface on `InstantPageTextItem` + +Three accessors become public so the adapter (defined in Task 2, in the same module) and external consumers can compose with the item. + +**Files:** +- Modify: `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` + +- [ ] **Step 1: Promote `attributedString` to public** + +Locate (around line 170): +```swift +public final class InstantPageTextItem: InstantPageItem { + let attributedString: NSAttributedString + public let lines: [InstantPageTextLine] +``` + +Replace with: +```swift +public final class InstantPageTextItem: InstantPageItem { + public let attributedString: NSAttributedString + public let lines: [InstantPageTextLine] +``` + +- [ ] **Step 2: Add public `attributesAtPoint(_:orNearest:)`** + +The existing internal method is at line 272 of `InstantPageTextItem.swift`: + +```swift + func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? { + let transformedPoint = CGPoint(x: point.x, y: point.y) + let boundsWidth = self.frame.width + for i in 0 ..< self.lines.count { + let line = self.lines[i] + + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + index -= 1 + } + } + if index >= 0 && index < self.attributedString.length { + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } + break + } + } + return nil + } +``` + +Leave that method untouched (it's still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). Immediately after it, add the new public method: + +```swift + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + if let direct = self.attributesAtPoint(point) { + return direct + } + guard orNearest, !self.lines.isEmpty else { + return nil + } + + let boundsWidth = self.frame.width + var nearestLineIndex = 0 + var nearestDistance = CGFloat.greatestFiniteMagnitude + for i in 0 ..< self.lines.count { + let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment) + let distance: CGFloat + if point.y < lineFrame.minY { + distance = lineFrame.minY - point.y + } else if point.y > lineFrame.maxY { + distance = point.y - lineFrame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestLineIndex = i + } + } + + let line = self.lines[nearestLineIndex] + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x)) + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if clampedX - lineFrame.minX < glyphStart { + index -= 1 + } + } + guard index >= 0, index < self.attributedString.length else { + return nil + } + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } +``` + +- [ ] **Step 3: Add public `textRangeRects(in:)`** + +The existing internal `rangeRects(in:)` is at line 369 of the file. Leave it untouched. Add a new public method that wraps it and converts the edge type. Place it directly after the existing `rangeRects(in:)` (so the implementations live together). + +```swift + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else { + return nil + } + let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height) + let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height) + return (result.rects, startEdge, endEdge) + } +``` + +`TextRangeRectEdge` is in `Display`, which is already imported by `InstantPageTextItem.swift` (verify the imports near the top of the file include `import Display` — it should). + +- [ ] **Step 4: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. + +Note: the user has unrelated WIP that may be breaking the build (a `sideInset` migration in `InstantPageLayout.swift` / `InstantPageControllerNode.swift` / `InstantPageTheme.swift`). If the build fails, check whether the failures mention `sideInset` and are inside those three files. If so, the failure is pre-existing and not from this task — flag it in your report and proceed. If failures are inside `InstantPageTextItem.swift`, those ARE from this task and need fixing. + +- [ ] **Step 5: Commit** + +```sh +git add submodules/InstantPageUI/Sources/InstantPageTextItem.swift +git commit -m "InstantPage: expose text-item attributedString and selection helpers" +``` + +--- + +### Task 2: `InstantPageMultiTextAdapter` + +A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed view, suitable for `TextSelectionNode`. + +**Files:** +- Create: `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` + +- [ ] **Step 1: Create the new file with the full adapter** + +Write `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` containing exactly: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore + +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int + let frameOrigin: CGPoint + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) { + let separator = NSAttributedString(string: "\n\n") + let combined = NSMutableAttributedString() + var entries: [Entry] = [] + for (index, item) in items.enumerated() { + let charOffset = combined.length + entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin)) + combined.append(item.attributedString) + if index != items.count - 1 { + combined.append(separator) + } + } + self.entries = entries + self.combinedString = combined + super.init() + self.isUserInteractionEnabled = false + } + + public var currentText: NSAttributedString? { + return self.combinedString + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + for entry in self.entries { + let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y) + if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) { + return (entry.charOffset + localIndex, attrs) + } + } + guard orNearest, !self.entries.isEmpty else { + return nil + } + var nearestEntry = self.entries[0] + var nearestDistance = CGFloat.greatestFiniteMagnitude + for entry in self.entries { + let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size) + let distance: CGFloat + if point.y < frame.minY { + distance = frame.minY - point.y + } else if point.y > frame.maxY { + distance = point.y - frame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestEntry = entry + } + } + let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y) + if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) { + return (nearestEntry.charOffset + localIndex, attrs) + } + return nil + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + var allRects: [CGRect] = [] + var startEdge: TextRangeRectEdge? + var endEdge: TextRangeRectEdge? + for entry in self.entries { + let itemLength = entry.item.attributedString.length + let entryRange = NSRange(location: entry.charOffset, length: itemLength) + let intersection = NSIntersectionRange(range, entryRange) + if intersection.length == 0 { + continue + } + let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) + guard let result = entry.item.textRangeRects(in: localRange) else { + continue + } + for rect in result.rects { + allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y)) + } + let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height) + let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height) + if startEdge == nil { + startEdge = translatedStart + } + endEdge = translatedEnd + } + guard !allRects.isEmpty, let start = startEdge, let end = endEdge else { + return nil + } + return (allRects, start, end) + } +} +``` + +- [ ] **Step 2: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. The new file is picked up automatically by `glob(["Sources/**/*.swift"])` in `submodules/InstantPageUI/BUILD` (already verified). `Display` is already a dep so `TextNodeProtocol` and `TextRangeRectEdge` are reachable. + +If the build fails inside the adapter file, fix and re-build. Pre-existing `sideInset` failures elsewhere are not from this task — see Task 1's note. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift +git commit -m "InstantPage: add multi-text adapter aggregating items as a TextNodeProtocol" +``` + +--- + +### Task 3: BUILD dep, import, and ivars + +Bring `TextSelectionNode` into the rich-bubble's BUILD graph and add the two new ivars. The lifecycle methods land in Task 4. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Add `TextSelectionNode` to BUILD deps** + +Locate the deps list. Currently it ends with these entries (order approximate; you'll see `GalleryUI` and `TextLoadingEffect` already present from prior work): + +``` + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextLoadingEffect", + "//submodules/TelegramUIPreferences", + "//submodules/GalleryUI", + ], +``` + +Append `"//submodules/TextSelectionNode",` immediately before the closing `],`: + +``` + "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", + "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", + "//submodules/TelegramUI/Components/ChatControllerInteraction", + "//submodules/TelegramUI/Components/TextLoadingEffect", + "//submodules/TelegramUIPreferences", + "//submodules/GalleryUI", + "//submodules/TextSelectionNode", + ], +``` + +- [ ] **Step 2: Add the import** + +In `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`, the imports currently look like: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import SwiftSignalKit +import AccountContext +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import ChatControllerInteraction +import InstantPageUI +import TelegramUIPreferences +import TextLoadingEffect +``` + +Append `import TextSelectionNode` immediately after `import TextLoadingEffect`: + +```swift +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox +import SwiftSignalKit +import AccountContext +import ChatMessageBubbleContentNode +import ChatMessageItemCommon +import ChatControllerInteraction +import InstantPageUI +import TelegramUIPreferences +import TextLoadingEffect +import TextSelectionNode +``` + +(There may be additional imports below `TextLoadingEffect` from prior work, e.g. `GalleryUI`. If so, place `import TextSelectionNode` after them — order within the import block doesn't matter as long as you don't break alphabetization that already exists.) + +- [ ] **Step 3: Add the two new ivars** + +Locate the existing block of stored properties (currently around lines 27-32 — the `linkProgress*` and `linkHighlightingNode` group): + +```swift + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? + private var linkProgressView: TextLoadingEffectView? +``` + +Append the two new ivars immediately after `linkProgressView`: + +```swift + private var linkProgressDisposable: Disposable? + private var linkProgressRects: [CGRect]? + private var linkHighlightingNode: LinkHighlightingNode? + private var linkProgressView: TextLoadingEffectView? + private var textSelectionAdapter: InstantPageMultiTextAdapter? + private var textSelectionNode: TextSelectionNode? +``` + +- [ ] **Step 4: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. Unused `import TextSelectionNode` and unused private ivars don't trigger Swift warnings, so `-warnings-as-errors` stays clean. + +- [ ] **Step 5: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: add TextSelectionNode dep and selection ivars" +``` + +--- + +### Task 4: Lifecycle hooks for entering / leaving context preview + +Override `updateIsExtractedToContextPreview(_:)` to set up the adapter + `TextSelectionNode` when the bubble is lifted into the context-menu preview, and `willUpdateIsExtractedToContextPreview(_:)` to tear them down when it leaves. + +**Files:** +- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` + +- [ ] **Step 1: Replace the empty preview-lifecycle stubs** + +The file currently contains two empty overrides (around the area near `updateSearchTextHighlightState`): + +```swift + override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + } + + override public func updateIsExtractedToContextPreview(_ value: Bool) { + } +``` + +Replace BOTH with: + +```swift + override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + if !value, let textSelectionNode = self.textSelectionNode { + self.textSelectionNode = nil + self.textSelectionAdapter = nil + textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in + textSelectionNode?.highlightAreaNode.removeFromSupernode() + textSelectionNode?.removeFromSupernode() + }) + } + } + + override public func updateIsExtractedToContextPreview(_ value: Bool) { + guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else { + return + } + + let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty } + guard !items.isEmpty else { + return + } + + let adapter = InstantPageMultiTextAdapter(items: items) + adapter.frame = self.containerNode.bounds + self.textSelectionAdapter = adapter + self.containerNode.addSubnode(adapter) + + let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + let theme = messageItem.presentationData.theme.theme + let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor + let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor + + let textSelectionNode = TextSelectionNode( + theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance), + strings: messageItem.presentationData.strings, + textNodeOrView: .node(adapter), + updateIsActive: { _ in }, + present: { [weak self] c, a in + guard let self, let item = self.item else { + return + } + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + item.controllerInteraction.presentControllerInCurrent(c, a) + } else { + item.controllerInteraction.presentGlobalOverlayController(c, a) + } + }, + rootView: { [weak rootNode] in + return rootNode?.view + }, + performAction: { [weak self] text, action in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) + } + ) + + let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes + textSelectionNode.enableCopy = enableCopy + textSelectionNode.enableQuote = false + textSelectionNode.enableTranslate = true + textSelectionNode.enableShare = true + textSelectionNode.enableLookup = true + + textSelectionNode.frame = self.containerNode.bounds + textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds + self.containerNode.addSubnode(textSelectionNode.highlightAreaNode) + self.containerNode.addSubnode(textSelectionNode) + self.textSelectionNode = textSelectionNode + } +``` + +Notes on this block: +- `chatControllerNode()` returns `ASDisplayNode?` from `ChatControllerInteraction`. The `rootView` closure weakly captures it. +- `TextSelectionTheme(selection:knob:isDark:)` — verified against `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:66`. +- `TextSelectionNode(theme:strings:textNodeOrView:updateIsActive:present:rootView:externalKnobSurface:performAction:)` — verified against the same file at line 296. We omit `externalKnobSurface` (defaulted to `nil`). +- `controllerInteraction.performTextSelectionAction(_:_:_:_:_:)` signature `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` — verified against `ChatControllerInteraction.swift:263`. +- The `subject` pattern `case let .messageOptions(_, _, info) = subject, case .reply = info` mirrors `ChatMessageTextBubbleContentNode.swift:1651-1654`. +- `enableLookup` defaults to `true` on `TextSelectionNode`; we set it explicitly for clarity. +- The capture `[weak self]` in `present` and `performAction` is enough; we don't need to retain `self` from inside those closures because the bubble node stays alive while the selection node does. + +- [ ] **Step 2: Build to verify** + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. + +Common likely errors and fixes: +- "value of type 'ChatMessageItemAssociatedData' has no member 'isCopyProtectionEnabled'" → property name has changed; grep `submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/` for the canonical name and substitute. +- "value of type 'PeerId' has no member 'isVerificationCodes'" → grep `submodules/TelegramCore/Sources/` for `isVerificationCodes` and import the right module if missing. +- "instance method 'presentControllerInCurrent' requires…" / similar — both `presentControllerInCurrent` and `presentGlobalOverlayController` exist on `ChatControllerInteraction` (lines 219 and 222 of `ChatControllerInteraction.swift`); their signatures take `(ViewController, Any?)`. +- Missing pre-existing failures from the user's `sideInset` migration are NOT from this task — see Task 1 Step 4's note. + +- [ ] **Step 3: Commit** + +```sh +git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +git commit -m "Rich bubble: drag-handle text selection in context-preview mode" +``` + +--- + +### Task 5: Manual smoke verification + +There are no automated tests for chat UI. Final task is a hands-on smoke test in the simulator. + +**Files:** none modified. Verification only. + +- [ ] **Step 1: Launch the app in the simulator** + +The build command run in earlier tasks produces the app binary; launch via Xcode (open `Telegram-iOS.xcworkspace` if the workspace exists locally) or run the Bazel-produced target. Sign in to a real test account. + +- [ ] **Step 2: Find or send a message with a rich-data IV preview** + +Send a Telegraph or t.me URL that produces an instant-view preview. The `debugRichText` experimental setting must be enabled for the preview to render via `ChatMessageRichDataBubbleContentNode` instead of the standard webpage card. (`ChatMessageBubbleItemNode.swift:386-387` is the gate.) + +- [ ] **Step 3: Long-press the bubble** + +Long-press the rich-data bubble. The framework lifts it into the context-preview popover, the message context menu appears alongside. + +- [ ] **Step 4: Drag-select text** + +Tap and drag inside the lifted preview. Drag-handles (knobs) should appear; the selection should extend across paragraphs as you drag. The selection background uses the incoming/outgoing `textSelectionColor` from the current theme. + +- [ ] **Step 5: Verify the action menu** + +The action menu (Copy / Translate / Share / Speak / Look Up) should appear anchored on the selection. Verify: +- Copy: places the selected text on the pasteboard. Multi-paragraph selections include `\n\n` between paragraphs. +- Quote menu item is **not** present (we explicitly disabled it). +- Translate / Share / Speak / Look Up route through the standard chat flow and behave normally. + +- [ ] **Step 6: Dismiss the context preview** + +Tap outside the preview or scroll away. The selection overlay and knobs fade out cleanly (alpha 1→0 over 0.2s), and the bubble returns to its normal state. Re-opening the context preview should re-create the selection nodes from scratch. + +- [ ] **Step 7: Sign-off** + +If steps 4–6 behave as described, the change is complete. If any step fails, capture: which step, observed vs. expected, any console output. Common deviations: +- Knobs never appear → confirm `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` are both `containerNode.bounds`. If `containerNode.bounds.size` is zero at the moment of preview entry, the selection node has nothing to draw on. +- Selection rects don't line up with text → confirm `adapter.frame.origin` is `.zero` and item frames in the layout are in `containerNode`-local coords (the layout origin is `(0, 0)` inside `containerNode`; the `(1, 1)` outer offset doesn't apply here because the selection nodes live INSIDE `containerNode`). +- Cross-paragraph drag stops at paragraph boundaries → confirm `attributesAtPoint` falls through to the nearest-entry branch when `orNearest == true`. +- Copy includes wrong text → confirm `combinedString` interleaves `"\n\n"` separators between items. + +--- + +## Self-review + +**Spec coverage:** +- Spec §"API exposure on `InstantPageTextItem`" → Task 1 (all three accessors). +- Spec §"`InstantPageMultiTextAdapter`" → Task 2 (full adapter). +- Spec §"Rich-bubble lifecycle wiring" — ivars/import/BUILD dep → Task 3; `updateIsExtractedToContextPreview` / `willUpdateIsExtractedToContextPreview` → Task 4. +- Spec §"Verification" → Task 5 (manual smoke). +- Spec §"Out of scope" stays out of scope; no tasks added. + +All spec sections covered. + +**Placeholder scan:** None of the "TBD" / "TODO" / "implement later" / "similar to Task N" patterns are present. Each step shows the actual code or command. + +**Type consistency:** +- `InstantPageMultiTextAdapter` is named the same in Task 2 (definition) and Tasks 3–4 (consumers). +- `Entry` struct is internal to the adapter and not referenced externally. +- `InstantPageTextItem.attributesAtPoint(_:orNearest:)` and `textRangeRects(in:)` are defined in Task 1 with the same signatures the adapter calls in Task 2. +- `TextSelectionNode` initializer parameters in Task 4 match the verified signature from `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:296` (`theme`, `strings`, `textNodeOrView`, `updateIsActive`, `present`, `rootView`, `performAction` — no `externalKnobSurface`). +- `TextSelectionTheme(selection:knob:isDark:)` matches the verified init at line 66 of the same file. +- `controllerInteraction.performTextSelectionAction` invocation matches `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` from line 263 of `ChatControllerInteraction.swift`. +- `subject` pattern `.messageOptions(_, _, info)` with `case .reply = info` mirrors text-bubble exactly. diff --git a/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md b/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md new file mode 100644 index 0000000000..0dbdf584e9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md @@ -0,0 +1,145 @@ +# Text selection in `ChatMessageRichDataBubbleContentNode` + +## Context + +`ChatMessageRichDataBubbleContentNode` renders an instant-page preview inline inside a chat bubble using `InstantPageLayout`/`InstantPageTile`/`InstantPageNode`. Users can already tap URLs and tap media (gallery), but cannot select any of the article text inside the preview. + +Two reference implementations exist: + +- `ChatMessageTextBubbleContentNode` (`submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/...`) — uses `TextSelectionNode` (drag-handle / knob style, action menu via `controllerInteraction.performTextSelectionAction`). Selection is gated on the bubble entering context-preview mode (`updateIsExtractedToContextPreview(true)`). The text-bubble has a single `textNode` so `TextSelectionNode` wraps it directly. +- `InstantPageControllerNode` (`submodules/InstantPageUI/Sources/...`) — paragraph-granularity highlight + `ContextMenuController`. Long-tap on a paragraph selects the whole paragraph; no drag-handles. + +The user picked the chat text-bubble model, gated only on context-preview mode. The structural challenge: rich-bubble has **many** `InstantPageTextItem`s spread across tiles, with no per-item rendering node — text is drawn directly into tile contexts via `CTLine`. + +## Goal + +Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all `InstantPageTextItem`s in the visible layout. Action menu integrates Copy / Translate / Share / Speak / Look Up via `controllerInteraction.performTextSelectionAction`. Quote is disabled (the IV preview text is not part of `item.message.text`, which the quote feature references). + +Out of scope: nested selection inside `InstantPageDetailsItem` / `InstantPageScrollableItem` (rich-bubble does not expand details or scroll inner content); selection during normal (non-preview) interaction; per-paragraph selection-only mode. + +## Design + +### Files touched + +- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote three accessors to public so a `TextNodeProtocol` adapter can build on top. +- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — a `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode`. +- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add the `import`, two new ivars, and lifecycle hooks for entering / leaving context preview. + +### API exposure on `InstantPageTextItem` + +Three accessors become public: + +```swift +public final class InstantPageTextItem: InstantPageItem { + public let attributedString: NSAttributedString // was `let` (package-private) + // ... + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? +} +``` + +- The new `attributesAtPoint(_:orNearest:)` extends the existing internal `attributesAtPoint(_:)`. When `orNearest == true` and no line directly contains the point, it picks the line with the smallest vertical distance to the point and runs `CTLineGetStringIndexForPosition` with the X clamped to that line's horizontal range. Mirrors what `TextNode.attributesAtPoint(orNearest:)` does. The existing internal `attributesAtPoint(_:)` is preserved (still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). +- The new `textRangeRects(in:)` wraps the existing internal `rangeRects(in:)`. It returns `Display.TextRangeRectEdge` (same `(x, y, height: CGFloat)` shape as the IV's local `InstantPageTextRangeRectEdge`). When the inner result has no edges (range maps to no rects), the public version returns `nil`. + +The existing internal members are not renamed — only new public surface is added. + +### `InstantPageMultiTextAdapter` + +A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view: + +```swift +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int // global char index where this item's text starts + let frameOrigin: CGPoint // item.frame.origin, in adapter-local coords + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) + + // TextNodeProtocol + public var currentText: NSAttributedString? { combinedString } + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? +} +``` + +**Construction.** `init(items:)` walks the list in document order. For each item: +1. Append `item.attributedString` to the running combined string. +2. Record `Entry(item, charOffset: combined.length-before-append, frameOrigin: item.frame.origin)`. +3. Append `"\n\n"` (plain) as a separator between entries (no separator after the last). + +The separator chars sit in the global string with no rects in `textRangeRects` (no entry contains them), so visual selection cleanly skips inter-paragraph gaps. They also keep paragraph breaks in the copied text. + +**`attributesAtPoint(_:orNearest:)`.** +1. Direct pass: for each entry, compute `localPoint = point - entry.frameOrigin`. If `entry.item.attributesAtPoint(localPoint, orNearest: false)` returns a hit, return `(entry.charOffset + localIndex, attrs)`. +2. Fallback (only when `orNearest == true`): pick the entry whose frame has the smallest vertical distance to `point.y` (zero if `point.y` is in the y-range, otherwise `min(|p.y - frame.minY|, |p.y - frame.maxY|)`), then call its `attributesAtPoint(localPoint, orNearest: true)`. Return `(entry.charOffset + localIndex, attrs)` or `nil` if even the nearest item returns nil. +3. Otherwise return `nil`. + +**`textRangeRects(in:)`.** Splits the global range across entries: +1. For each entry whose `[charOffset, charOffset + item.attributedString.length)` intersects the requested range: + - Compute the local sub-range within the entry. + - Call `entry.item.textRangeRects(in: localRange)`. + - Translate each rect by `entry.frameOrigin`. + - First contributing entry: take its `start` edge translated by `frameOrigin`. + - Each contributing entry updates `end` to its translated `end` edge. +2. If no entry contributed any rects, return `nil`. +3. Otherwise return `(allRects, start, end)`. + +The adapter is invisible — it has no contents and is purely a `TextNodeProtocol` provider. It exists as an `ASDisplayNode` only because the protocol requires it. + +### Rich-bubble lifecycle wiring + +**New ivars:** + +```swift +private var textSelectionAdapter: InstantPageMultiTextAdapter? +private var textSelectionNode: TextSelectionNode? +``` + +**Imports / BUILD:** add `import TextSelectionNode` and `//submodules/TextSelectionNode` to the rich-bubble's BUILD deps. `TextRangeRectEdge` lives in `Display`, already imported. + +**Entering preview** (`updateIsExtractedToContextPreview(true)`): +1. Bail out if `textSelectionNode != nil`, no `item`, no `currentPageLayout`, or no `chatControllerNode`. +2. Filter `currentPageLayout.layout.items` to selectable, non-empty `InstantPageTextItem`s. +3. Construct `InstantPageMultiTextAdapter(items:)`, set its frame to `containerNode.bounds`, add it to `containerNode`. +4. Pick incoming/outgoing `textSelectionColor` and `textSelectionKnobColor` from the presentation theme. +5. Construct `TextSelectionNode` with: + - `textNodeOrView: .node(adapter)` + - `present`: routes to `controllerInteraction.presentControllerInCurrent` when `item.associatedData.subject` matches `.messageOptions(_, _, info)` with `case .reply = info`, else `presentGlobalOverlayController` — same branch the text-bubble uses (see `ChatMessageTextBubbleContentNode.swift:1651-1654`). + - `rootView`: returns the `chatControllerNode` view. + - `performAction`: routes to `controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)`. +6. Set flags: + - `enableCopy = (!associatedData.isCopyProtectionEnabled && !message.isCopyProtected()) || message.id.peerId.isVerificationCodes` — same rule as text-bubble. + - `enableQuote = false` — quote-replies reference `item.message.text`; IV preview text isn't in there. + - `enableTranslate = true`, `enableShare = true`, `enableLookup = true`. +7. Set `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` to `containerNode.bounds`. +8. Add `highlightAreaNode` then `textSelectionNode` to `containerNode`. + +**Leaving preview** (`willUpdateIsExtractedToContextPreview(false)`): mirror text-bubble's tear-down — animate alpha 1→0 over 0.2s on both `highlightAreaNode` and the `textSelectionNode` itself, remove from supernode in the completion, clear both ivars synchronously so a subsequent re-entry creates fresh nodes. + +**Subnode ordering** inside `containerNode` (bottom → top): tiles → `linkHighlightingNode` (touch state, from earlier task) → `linkProgressView` (in-flight URL shimmer) → adapter (invisible) → `textSelectionNode.highlightAreaNode` → `textSelectionNode`. Order preserved by insertion sequence. + +**Coordinate strategy.** Both adapter and `textSelectionNode` use `containerNode.bounds`. The IV layout origin is `(0, 0)` inside `containerNode`, and `InstantPageTextItem.frame` is in that space — so the adapter's local coords line up with item frames directly without a `(1, 1)` translation. (The `(1, 1)` translation only applies to points coming from the bubble-content-node coord space, e.g., in `tapActionAtPoint`.) + +## Verification + +- Build green: `python3 build-system/Make/Make.py … build … --configuration=debug_sim_arm64`. No automated tests in this project. +- Manual test in simulator: + 1. Find or send a message with a rich-data IV preview (`debugRichText` setting must be on). + 2. Long-press the bubble — it lifts into the context-preview popover, the message context menu appears alongside. + 3. In the lifted preview, select text by tapping and dragging knobs. Selection extends across paragraphs. + 4. Action menu (Copy / Translate / Share / Speak / Look Up) appears anchored on the selection. Confirm Copy puts the selected text on the pasteboard, with `\n\n` between paragraphs. + 5. Quote menu item is **not** present. + 6. Dismiss the context preview — selection overlay and knobs fade out cleanly. + +## Open follow-ups (not in this spec) + +- Cross-paragraph selection inside expanded `InstantPageDetailsItem` / scrollable `InstantPageScrollableItem` content (rich-bubble doesn't currently expand or scroll those). +- Spoiler awareness on selection (text-bubble has spoiler-aware selection that triggers reveal). IV text items currently don't carry spoiler attributes through `attributedString` in a way that's symmetric with chat text, so deferred. +- Search-text highlighting within the IV preview (`updateSearchTextHighlightState`). diff --git a/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift new file mode 100644 index 0000000000..6c3051114c --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift @@ -0,0 +1,103 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import TelegramCore + +public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { + private struct Entry { + let item: InstantPageTextItem + let charOffset: Int + let frameOrigin: CGPoint + } + + private let entries: [Entry] + private let combinedString: NSAttributedString + + public init(items: [InstantPageTextItem]) { + let separator = NSAttributedString(string: "\n\n") + let combined = NSMutableAttributedString() + var entries: [Entry] = [] + for (index, item) in items.enumerated() { + let charOffset = combined.length + entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin)) + combined.append(item.attributedString) + if index != items.count - 1 { + combined.append(separator) + } + } + self.entries = entries + self.combinedString = combined + super.init() + self.isUserInteractionEnabled = false + } + + public var currentText: NSAttributedString? { + return self.combinedString + } + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + for entry in self.entries { + let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y) + if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) { + return (entry.charOffset + localIndex, attrs) + } + } + guard orNearest, !self.entries.isEmpty else { + return nil + } + var nearestEntry = self.entries[0] + var nearestDistance = CGFloat.greatestFiniteMagnitude + for entry in self.entries { + let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size) + let distance: CGFloat + if point.y < frame.minY { + distance = frame.minY - point.y + } else if point.y > frame.maxY { + distance = point.y - frame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestEntry = entry + } + } + let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y) + if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) { + return (nearestEntry.charOffset + localIndex, attrs) + } + return nil + } + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + var allRects: [CGRect] = [] + var startEdge: TextRangeRectEdge? + var endEdge: TextRangeRectEdge? + for entry in self.entries { + let itemLength = entry.item.attributedString.length + let entryRange = NSRange(location: entry.charOffset, length: itemLength) + let intersection = NSIntersectionRange(range, entryRange) + if intersection.length == 0 { + continue + } + let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) + guard let result = entry.item.textRangeRects(in: localRange) else { + continue + } + for rect in result.rects { + allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y)) + } + let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height) + let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height) + if startEdge == nil { + startEdge = translatedStart + } + endEdge = translatedEnd + } + guard !allRects.isEmpty, let start = startEdge, let end = endEdge else { + return nil + } + return (allRects, start, end) + } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index c90d09e5b3..10826a6c94 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -175,7 +175,7 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin } public final class InstantPageTextItem: InstantPageItem { - let attributedString: NSAttributedString + public let attributedString: NSAttributedString public let lines: [InstantPageTextLine] let rtlLineIndices: Set public var frame: CGRect @@ -300,7 +300,7 @@ public final class InstantPageTextItem: InstantPageItem { let boundsWidth = self.frame.width for i in 0 ..< self.lines.count { let line = self.lines[i] - + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) { var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) @@ -321,7 +321,53 @@ public final class InstantPageTextItem: InstantPageItem { } return nil } - + + public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { + if let direct = self.attributesAtPoint(point) { + return direct + } + guard orNearest, !self.lines.isEmpty else { + return nil + } + + let boundsWidth = self.frame.width + var nearestLineIndex = 0 + var nearestDistance = CGFloat.greatestFiniteMagnitude + for i in 0 ..< self.lines.count { + let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment) + let distance: CGFloat + if point.y < lineFrame.minY { + distance = lineFrame.minY - point.y + } else if point.y > lineFrame.maxY { + distance = point.y - lineFrame.maxY + } else { + distance = 0.0 + } + if distance < nearestDistance { + nearestDistance = distance + nearestLineIndex = i + } + } + + let line = self.lines[nearestLineIndex] + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) + let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x)) + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0)) + if index == self.attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if clampedX - lineFrame.minX < glyphStart { + index -= 1 + } + } + guard index >= 0, index < self.attributedString.length else { + return nil + } + return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) + } + private func attributeRects(name: NSAttributedString.Key, at index: Int) -> [CGRect]? { var range = NSRange() let _ = self.attributedString.attribute(name, at: index, effectiveRange: &range) @@ -396,9 +442,9 @@ public final class InstantPageTextItem: InstantPageItem { guard range.length != 0 else { return nil } - + let boundsWidth = self.frame.width - + var rects: [(CGRect, CGRect)] = [] var startEdge: InstantPageTextRangeRectEdge? var endEdge: InstantPageTextRangeRectEdge? @@ -419,11 +465,11 @@ public final class InstantPageTextItem: InstantPageItem { rightOffset = ceil(secondaryOffset) } } - + let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) - + let width = max(0.0, abs(rightOffset - leftOffset)) - + if line.range.contains(range.lowerBound) { let offsetX = floor(CTLineGetOffsetForStringIndex(line.line, range.lowerBound, nil)) startEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) @@ -437,7 +483,7 @@ public final class InstantPageTextItem: InstantPageItem { let primaryOffset = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound - 1, &secondaryOffset)) secondaryOffset = floor(secondaryOffset) let nextOffet = floor(CTLineGetOffsetForStringIndex(line.line, range.upperBound, &secondaryOffset)) - + if primaryOffset != secondaryOffset { offsetX = secondaryOffset } else { @@ -446,7 +492,7 @@ public final class InstantPageTextItem: InstantPageItem { } endEdge = InstantPageTextRangeRectEdge(x: lineFrame.minX + offsetX, y: lineFrame.minY, height: lineFrame.height) } - + rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + min(leftOffset, rightOffset), y: lineFrame.minY), size: CGSize(width: width, height: lineFrame.size.height)))) } } @@ -455,7 +501,16 @@ public final class InstantPageTextItem: InstantPageItem { } return nil } - + + public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { + guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else { + return nil + } + let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height) + let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height) + return (result.rects, startEdge, endEdge) + } + public func lineRects() -> [CGRect] { let boundsWidth = self.frame.width var rects: [CGRect] = [] diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index 28bdeb5f94..0615ab5b69 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -22,6 +22,7 @@ swift_library( "//submodules/TelegramUI/Components/ChatControllerInteraction", "//submodules/TelegramUI/Components/TextLoadingEffect", "//submodules/TelegramUIPreferences", + "//submodules/TextSelectionNode", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index f09fc1549f..a5e003fda8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -12,6 +12,7 @@ import ChatControllerInteraction import InstantPageUI import TelegramUIPreferences import TextLoadingEffect +import TextSelectionNode public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode { public final class ContainerNode: ASDisplayNode { @@ -30,7 +31,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode private var linkProgressRects: [CGRect]? private var linkHighlightingNode: LinkHighlightingNode? private var linkProgressView: TextLoadingEffectView? - + private var textSelectionAdapter: InstantPageMultiTextAdapter? + private var textSelectionNode: TextSelectionNode? + override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { @@ -697,9 +700,82 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode } override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { + if !value, let textSelectionNode = self.textSelectionNode { + self.textSelectionNode = nil + self.textSelectionAdapter = nil + textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in + textSelectionNode?.highlightAreaNode.removeFromSupernode() + textSelectionNode?.removeFromSupernode() + }) + } } - + override public func updateIsExtractedToContextPreview(_ value: Bool) { + guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else { + return + } + + let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty } + guard !items.isEmpty else { + return + } + + let adapter = InstantPageMultiTextAdapter(items: items) + adapter.frame = self.containerNode.bounds + self.textSelectionAdapter = adapter + self.containerNode.addSubnode(adapter) + + let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) + let theme = messageItem.presentationData.theme.theme + let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor + let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor + + let textSelectionNode = TextSelectionNode( + theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance), + strings: messageItem.presentationData.strings, + textNodeOrView: .node(adapter), + updateIsActive: { _ in }, + present: { [weak self] c, a in + guard let self, let item = self.item else { + return + } + if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + item.controllerInteraction.presentControllerInCurrent(c, a) + } else { + item.controllerInteraction.presentGlobalOverlayController(c, a) + } + }, + rootView: { [weak rootNode] in + return rootNode?.view + }, + performAction: { [weak self] text, action in + guard let self, let item = self.item else { + return + } + item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) + } + ) + + let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes + textSelectionNode.enableCopy = enableCopy + + var enableOtherActions = true + if let subject = messageItem.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { + enableOtherActions = false + } + + textSelectionNode.enableQuote = false + textSelectionNode.enableTranslate = enableOtherActions + textSelectionNode.enableShare = enableOtherActions && enableCopy + textSelectionNode.enableLookup = true + textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions + + textSelectionNode.frame = self.containerNode.bounds + textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds + self.containerNode.insertSubnode(textSelectionNode.highlightAreaNode, at: 0) + self.containerNode.addSubnode(textSelectionNode) + self.textSelectionNode = textSelectionNode } override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { From 6561adff9453d23f71910faaa166c994aa8a6d8d Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 2 May 2026 00:40:06 +0200 Subject: [PATCH 67/69] Update tgcalls --- submodules/TgVoipWebrtc/tgcalls | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TgVoipWebrtc/tgcalls b/submodules/TgVoipWebrtc/tgcalls index c502af7a2e..5435d9d80f 160000 --- a/submodules/TgVoipWebrtc/tgcalls +++ b/submodules/TgVoipWebrtc/tgcalls @@ -1 +1 @@ -Subproject commit c502af7a2e87595f1fbb8b98a4ff6942691f85cb +Subproject commit 5435d9d80f9325efc40bded13091ff0c2b639637 From ae37006ee43eefad6e8258983d79e9ba5e966002 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 2 May 2026 00:52:37 +0200 Subject: [PATCH 68/69] Cleanup --- ...stbox-to-telegramengine-refactor-wave-1.md | 983 ------------- .../plans/2026-04-17-listview-pin-to-edge.md | 223 --- ...aresource-to-enginemediaresource-wave-2.md | 880 ----------- ...-04-18-postbox-to-telegramengine-wave-3.md | 968 ------------- ...-04-18-postbox-to-telegramengine-wave-4.md | 500 ------- ...-04-18-postbox-to-telegramengine-wave-5.md | 381 ----- ...-04-19-postbox-to-telegramengine-wave-6.md | 374 ----- .../2026-04-20-decrypt-match-python-port.md | 539 ------- ...04-20-postbox-to-telegramengine-wave-10.md | 194 --- ...-04-20-postbox-to-telegramengine-wave-7.md | 95 -- ...-04-20-postbox-to-telegramengine-wave-8.md | 103 -- ...-04-20-postbox-to-telegramengine-wave-9.md | 162 --- ...04-21-swifttl-layered-schema-generation.md | 1037 ------------- ...4-21-textstyleeditscreen-caret-tracking.md | 351 ----- ...4-contactlistpeer-engine-peer-migration.md | 944 ------------ ...6-04-24-foundpeer-engine-peer-migration.md | 1287 ----------------- ...een-recentActions-engine-peer-migration.md | 411 ------ ...eerInfoController-engine-peer-migration.md | 1037 ------------- ...nfoscreen-helpers-engine-peer-migration.md | 658 --------- ...foscreendata-peer-engine-peer-migration.md | 164 --- ...24-peertokentitle-engine-peer-migration.md | 395 ----- .../2026-04-24-rcp-peers-engine-migration.md | 666 --------- ...lparticipant-peer-engine-peer-migration.md | 860 ----------- ...-04-24-sendaspeer-engine-peer-migration.md | 666 --------- ...eerhistory-openclearhistory-engine-peer.md | 116 -- ...4-25-peerinfo-enclosingpeer-engine-peer.md | 516 ------- ...infoscreendata-linked-peers-engine-peer.md | 106 -- ...creendata-savedmessagespeer-engine-peer.md | 70 - ...04-25-phn-peer-stored-field-engine-peer.md | 62 - ...103-chat-recent-actions-controller-node.md | 347 ----- ...count-manager-store-resource-data-drain.md | 260 ---- ...unt-manager-resource-data-drain-3-sites.md | 331 ----- ...device-contact-info-subject-engine-peer.md | 489 ------- ...4-26-postbox-wave-106-import-drop-sweep.md | 493 ------- ...-wave-106-pivot-engine-data-incremental.md | 223 --- .../2026-04-30-typing-draft-send-delay.md | 703 --------- .../2026-05-01-groupref-ssrc-discovery.md | 1121 -------------- ...-rich-bubble-instant-page-link-handling.md | 655 --------- .../2026-05-01-rich-bubble-text-selection.md | 627 -------- ...05-01-rich-data-bubble-scroll-to-anchor.md | 504 ------- ...26-04-30-typing-draft-send-delay-design.md | 178 --- ...26-05-01-groupref-ssrc-discovery-design.md | 354 ----- ...instant-page-underline-rendering-design.md | 101 -- ...ubble-instant-page-link-handling-design.md | 180 --- ...05-01-rich-bubble-text-selection-design.md | 145 -- ...ich-data-bubble-scroll-to-anchor-design.md | 149 -- 46 files changed, 21608 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md delete mode 100644 docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md delete mode 100644 docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md delete mode 100644 docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md delete mode 100644 docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md delete mode 100644 docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md delete mode 100644 docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md delete mode 100644 docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md delete mode 100644 docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md delete mode 100644 docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-7.md delete mode 100644 docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md delete mode 100644 docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md delete mode 100644 docs/superpowers/plans/2026-04-21-swifttl-layered-schema-generation.md delete mode 100644 docs/superpowers/plans/2026-04-21-textstyleeditscreen-caret-tracking.md delete mode 100644 docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-makePeerInfoController-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-peerinfoscreendata-peer-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-peertokentitle-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md delete mode 100644 docs/superpowers/plans/2026-04-25-clearpeerhistory-openclearhistory-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-25-peerinfo-enclosingpeer-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-25-peerinfoscreendata-linked-peers-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-25-peerinfoscreendata-savedmessagespeer-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-25-phn-peer-stored-field-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-106-import-drop-sweep.md delete mode 100644 docs/superpowers/plans/2026-04-26-postbox-wave-106-pivot-engine-data-incremental.md delete mode 100644 docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md delete mode 100644 docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md delete mode 100644 docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md delete mode 100644 docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md delete mode 100644 docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md delete mode 100644 docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md delete mode 100644 docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md delete mode 100644 docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md delete mode 100644 docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md delete mode 100644 docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md delete mode 100644 docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md diff --git a/docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md b/docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md deleted file mode 100644 index 26d958661b..0000000000 --- a/docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md +++ /dev/null @@ -1,983 +0,0 @@ -# Postbox → TelegramEngine refactor, wave 1 — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drop the direct `import Postbox` dependency from the first 10 leaf consumer submodules (one file each), routing data access through `TelegramEngine` while preserving behavior exactly. - -**Architecture:** For each of the 10 modules, apply the same deterministic playbook: inventory every Postbox reference in its single Postbox-importing file, swap bare Postbox type names for their engine typealiases (`PeerId` → `EnginePeer.Id`, etc.), replace imperative Postbox calls with existing engine methods or new thin engine wrappers added to TelegramCore in a preparatory commit, remove `import Postbox` and the Bazel dep, and run the full project build to verify. - -**Tech Stack:** Swift, Bazel (primary build system), Postbox (storage lib being made opaque), TelegramCore + TelegramEngine (the public facade), SSignalKit (signals). - -**Spec:** [docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md](../specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md) - ---- - -## Background the executor needs - -There are no unit tests in this project (`CLAUDE.md`: "No tests are used at the moment"). **The only verification is the full project build.** Every task ends with a full build that must go green before the next task starts. - -### The full build command - -Run from the repo root (`/Users/ali/build/telegram/telegram-ios`): - -```bash -source ~/.zshrc 2>/dev/null; \ -PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \ - python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development \ - --gitCodesigningUseCurrent \ - --buildNumber 1 \ - --configuration debug_sim_arm64 -``` - -(`source ~/.zshrc` picks up `TELEGRAM_CODESIGNING_GIT_PASSWORD` and other env exports that the Claude Code bash shell doesn't inherit by default.) - -It is slow. Do not shortcut it with `bazel build //submodules/X` — the spec chose full build per module. - -### Engine typealias cheat sheet (already in TelegramCore) - -When removing `import Postbox`, bare Postbox names in the file must be swapped for their engine equivalents. The ones that exist as typealiases today (confirmed by grep over `submodules/TelegramCore/Sources/TelegramEngine/`): - -- `PeerId` → `EnginePeer.Id` -- `MessageId` → `EngineMessage.Id` -- `MessageIndex` → `EngineMessage.Index` -- `MessageTags` → `EngineMessage.Tags` -- `MessageAttribute` → `EngineMessage.Attribute` -- `MessageFlags` → `EngineMessage.Flags` -- `MessageForwardInfo` → `EngineMessage.ForwardInfo` -- `MediaId` → `EngineMedia.Id` -- `PreferencesEntry` → `EnginePreferencesEntry` -- `TempBox` (the singleton helper) → `EngineTempBox` -- `PinnedItemId` → `EngineChatList.PinnedItem.Id` - -If a task needs a Postbox type that has **no** existing engine typealias, the task may add one in `TelegramCore` (trivial `public typealias EngineX = X`) in the preparatory commit — this is explicitly allowed by the spec. - -### Engine wrapper locations (per the spec) - -- Data reads / subscriptions → new `TelegramEngine.EngineData.Item..` in `submodules/TelegramCore/Sources/TelegramEngine/Data/Data.swift`. -- Imperative signal-returning calls → new method on `Peers` / `Messages` / `Resources` / `AccountData` under `submodules/TelegramCore/Sources/TelegramEngine//`. -- Media-resource access → extend `engine.resources` (e.g. `engine.resources.data(...)`, `engine.resources.status(...)`), forwarding to `account.postbox.mediaBox.*` internally. -- Consumer-run `account.postbox.transaction { ... }` → a specific purpose-built engine method. No generic transaction escape hatch. - -### Static-check commands (run before the build in every task) - -```bash -grep -R "^import Postbox" submodules//Sources # must return empty -grep "submodules/Postbox" submodules//BUILD # must return empty -``` - -### Commit convention - -Per module, up to two commits (optional first, required second): - -1. `TelegramCore: add ` — only if new engine wrappers were needed. -2. `: drop direct Postbox dependency` — consumer edits + BUILD change. - -Always use a HEREDOC commit body. No `--amend`. Every commit must build. - -**TelegramCore wrapper commit template** (used by any task's Step 2a when engine wrappers/typealiases are added): - -```bash -git add submodules/TelegramCore/... -git commit -m "$(cat <<'EOF' -TelegramCore: add - -Prepares for to drop Postbox. -Searched TelegramEngine/ for existing equivalents: . - - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - -### Build-failure handling (applies to every task's Step 6) - -When the full build fails after a consumer edit: - -- Read the **first** compiler error in the build output. -- If it's a name-resolution or type error in the module file being refactored, fix the mapping in that file and rebuild. -- If it's in a **different** module that depends on the module being refactored, a public signature changed unexpectedly. Either (a) revert that signature change so the public surface stays identical, or (b) if the new surface is genuinely better, extend the fix to the downstream call site **in the same commit**. -- If fixing would require editing a module outside the wave-1 list — or would require aliasing an umbrella type banned by spec rule 2 (`Postbox`, `Account`, `MediaBox`) — revert all changes from the current task and mark the module **Abandoned** in its task body with a one-line reason. Do NOT substitute a different module; the wave's done-count simply goes down by one. - -### The 10 modules (from the spec's deterministic selection rule) - -Reverse-dep count (over the 30-candidate pool) ascending, alphabetical tiebreak. Verified by running the selection script in Task 0: - -1. ActionSheetPeerItem — **ABANDONED** (see Task 1 body). Public init takes `postbox: Postbox`; ShareController caller is out-of-wave. -2. ChatInterfaceState — `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift` — DONE -3. ChatListSearchRecentPeersNode — **ABANDONED** (see Task 3 body). Public init takes `postbox: Postbox`; ShareController + ChatListUI callers are out-of-wave. -4. ChatSendMessageActionUI — `submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift` -5. ContactListUI — `submodules/ContactListUI/Sources/ContactListNode.swift` -6. DirectMediaImageCache — **ABANDONED** (see Task 6 body). Public init takes `account: Account`; six out-of-wave callers. -7. DrawingUI — `submodules/DrawingUI/Sources/DrawingScreen.swift` -8. FetchManagerImpl — **ABANDONED** (see Task 8 body). Public init takes `postbox: Postbox`; TelegramUI caller is out-of-wave. -9. GalleryData — **ABANDONED** (see Task 9 body). Four public functions take `Media`/`Message` as parameters; refactor cascades into many out-of-wave downstream types (`AvatarGalleryEntry`, `MessageReference`, etc.). Good candidate for a bespoke future wave that migrates the domain types together. -10. ICloudResources — **ABANDONED** (see Task 10 body). Class conforms to `TelegramMediaResource` and inherits `isEqual(to: MediaResource)`; overriding that without aliasing the `MediaResource` protocol isn't possible. - -**Wave-1 done-count: 4** (Tasks 2, 4, 5, 7 done; Tasks 1, 3, 6, 8, 9, 10 abandoned). - -Per the spec's **abandonment protocol**, if a module hits an unresolvable blocker (requires aliasing an umbrella type such as `Postbox`/`Account`/`MediaBox`, or requires editing a module outside the wave-1 list), it is marked Abandoned in its task body and **not substituted**. The wave's done-count goes down by one; fallback modules are not pulled into the wave mid-execution. A later wave can revisit the abandoned module with tools not available in wave 1 (e.g. a real engine wrapper rather than a typealias, or a refactor that migrates the caller first). - ---- - -## Task 0: Verify selection and baseline build - -**Files:** -- Read: `submodules//BUILD` - -- [ ] **Step 1: Re-run the selection script to confirm the 10** - -Save and run this Python snippet from the repo root. It should output exactly the 10 modules listed above, in that order. - -```bash -python3 <<'EOF' -import os, re -pool = ["ActionSheetPeerItem","ChatInterfaceState","ChatListSearchRecentPeersNode","ChatSendMessageActionUI","ContactListUI","DirectMediaImageCache","DrawingUI","FetchManagerImpl","GalleryData","HorizontalPeerItem","ICloudResources","InAppPurchaseManager","InstantPageCache","InviteLinksUI","ItemListAvatarAndNameInfoItem","ItemListPeerItem","ItemListStickerPackItem","MapResourceToAvatarSizes","PhotoResources","PlatformRestrictionMatching","PresentationDataUtils","PromptUI","SaveToCameraRoll","SelectablePeerNode","ShareItems","SoftwareVideo","StickerPeekUI","StickerResources","TelegramIntents","TelegramNotices"] -deps = {} -for m in pool: - p = f"submodules/{m}/BUILD" - txt = open(p).read() if os.path.exists(p) else "" - deps[m] = {o for o in pool if o != m and re.search(rf'//submodules/{re.escape(o)}(:|"|$)', txt)} -rdep = {m:0 for m in pool} -for m,ds in deps.items(): - for d in ds: rdep[d]+=1 -for m in sorted(pool, key=lambda m:(rdep[m],m))[:10]: - print(m, rdep[m]) -EOF -``` - -Expected output (one per line): `ActionSheetPeerItem 0`, `ChatInterfaceState 0`, `ChatListSearchRecentPeersNode 0`, `ChatSendMessageActionUI 0`, `ContactListUI 0`, `DirectMediaImageCache 0`, `DrawingUI 0`, `FetchManagerImpl 0`, `GalleryData 0`, `ICloudResources 0`. - -If the output differs, stop and investigate — someone changed a BUILD file since the spec was written. - -- [ ] **Step 2: Run the baseline full build** - -Run the full build command above. Expected: PASS (green master). If it fails, stop — we need a green baseline before changing anything. Do not attempt to fix pre-existing build breakage as part of this plan. - -- [ ] **Step 3: No commit** - -Task 0 produces no code changes. - ---- - -## Task 1: Refactor `ActionSheetPeerItem` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** Refactoring this module requires either (a) typealiasing the `Postbox` class itself (banned — see spec §Guiding rules rule 2: umbrella-type typealiases rename without encapsulating) or (b) editing `submodules/ShareController/` which is not in the wave-1 list. The module's designated init takes `postbox: Postbox` as a parameter and its sole out-of-wave caller (ShareController) passes `info.account.stateManager.postbox` directly, so there is no path to drop the `import Postbox` here without crossing the wave boundary or violating rule 2. Per the spec's **abandonment protocol**, the module is skipped for this wave. Wave-1 done-count is therefore 9, not 10. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift` -- Modify: `submodules/ActionSheetPeerItem/BUILD` - -**Starting inventory** (computed during planning): - -Grep for common Postbox API/type names in `ActionSheetPeerItem.swift` returned zero hits on `mediaBox`, `transaction`, `PostboxView`, `combinedView`, `PeerId`, `MessageId`, `MediaResource`, `CachedPeerData`, etc. The `import Postbox` line appears unused. Confirm this during inventory — it's the most likely case, but other Postbox symbols (e.g. types referenced inside a parameter type) may still be present. (Subsequent inventory discovered the module does take `postbox: Postbox` as a parameter type — this is what makes the module unrefactorable under the wave-1 rules.) - -- [ ] **Step 1: Inventory** - -Read `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift` top to bottom. Record every identifier that is Postbox-owned. If the inventory is empty, skip straight to Step 4. - -Run this helper grep too: - -```bash -grep -nE "\b(PeerId|MessageId|MessageIndex|MessageTags|MessageAttribute|MessageFlags|Peer|Media|MediaId|MediaResource|PostboxView|CachedPeerData|PreferencesEntry|ChatListIndex|PeerReference|TelegramMediaFile|TelegramMediaImage|Namespaces|TempBox)\b" submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift -``` - -- [ ] **Step 2: Map each reference to a replacement** - -For each finding from Step 1, decide: existing engine typealias (see cheat sheet), existing engine method, existing TelegramCore non-Postbox export, or new engine wrapper. Record the mapping in your working notes. If a new wrapper is needed, it is added in Task 1a before Task 1 continues. - -- [ ] **Step 2a: (Only if Step 2 identified a missing engine wrapper/typealias) Add to TelegramCore** - -Edit the relevant file under `submodules/TelegramCore/Sources/TelegramEngine//` or `submodules/TelegramCore/Sources/TelegramEngine/Data/Data.swift`, following the wrapper-location rules in the Background section. Keep the wrapper minimal: a single typealias for name-only adds, or a thin method that forwards to the underlying Postbox call for imperative ones. - -Run the full build. It must pass. Commit: - -```bash -git add submodules/TelegramCore/... -git commit -m "$(cat <<'EOF' -TelegramCore: add - -Prepares for ActionSheetPeerItem to drop Postbox. - - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - -Skip this step if Step 2 didn't identify any missing wrappers. - -- [ ] **Step 3: Edit the consumer file** - -In `submodules/ActionSheetPeerItem/Sources/ActionSheetPeerItem.swift`: - -- Apply every mapping from Step 2. -- Remove the line `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ActionSheetPeerItem/BUILD`. Remove the line `"//submodules/Postbox:Postbox",` from the `deps` array. Leave the rest of the BUILD untouched. - -- [ ] **Step 5: Static checks** - -Run: - -```bash -grep -R "^import Postbox" submodules/ActionSheetPeerItem/Sources # expect: empty -grep "submodules/Postbox" submodules/ActionSheetPeerItem/BUILD # expect: empty -``` - -Both must return no output. If either produces a hit, go back to Step 3 or Step 4. - -- [ ] **Step 6: Full project build** - -Run the full build command from the Background section. Expected: PASS. - -If it fails: -- Read the first error. If it's a name-resolution error in `ActionSheetPeerItem.swift`, fix the mapping and rebuild. -- If it's in a *different* module that depends on `ActionSheetPeerItem`, you changed a public signature unexpectedly; either revert that signature change or, if it's genuinely better, extend the fix to that downstream call site in the same commit. -- If the fix would require editing a module outside the wave-1 list, revert all Task 1 changes and skip to the next fallback module listed in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ActionSheetPeerItem/ -git commit -m "$(cat <<'EOF' -ActionSheetPeerItem: drop direct Postbox dependency - -Route data access through TelegramEngine/TelegramCore; remove the -Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 2: Refactor `ChatInterfaceState` - -**Files:** -- Modify: `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift` -- Modify: `submodules/ChatInterfaceState/BUILD` - -**Starting inventory** (computed during planning): file references `MessageId` (×2) and `MediaResource` (×3). No `mediaBox`, `transaction`, `combinedView`, or `PostboxView` usage. This is a **type-reference-only** case — expected replacements are `MessageId` → `EngineMessage.Id` and `MediaResource` stays as-is only if a typealias exists, otherwise a typealias `EngineMediaResource = MediaResource` is added in TelegramCore. - -- [ ] **Step 1: Inventory** - -Read `submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift`. Confirm the grep below matches the planning inventory and records exact line numbers and declaration contexts (parameter types, property types, return types, generic arguments). - -```bash -grep -nE "\b(PeerId|MessageId|MessageIndex|MessageTags|MessageAttribute|MessageFlags|Peer|Media|MediaId|MediaResource|PostboxView|CachedPeerData|PreferencesEntry|ChatListIndex|PeerReference|TelegramMediaFile|TelegramMediaImage|Namespaces|TempBox)\b" submodules/ChatInterfaceState/Sources/ChatInterfaceState.swift -``` - -- [ ] **Step 2: Map each reference** - -- `MessageId` → `EngineMessage.Id` (existing typealias, no wrapper needed). -- `MediaResource`: search `submodules/TelegramCore/Sources/TelegramEngine/` for a `public typealias Engine.*Resource.*= MediaResource`. If present, use it. If absent, proceed to Step 2a and add a typealias `public typealias EngineMediaResource = MediaResource` in `submodules/TelegramCore/Sources/TelegramEngine/Resources/` (new file `EngineMediaResource.swift`, or the most natural existing file in that folder). - -- [ ] **Step 2a: (Only if needed) Add engine typealias(es) in TelegramCore** - -For each Postbox type without an engine typealias, add a `public typealias Engine = ` in the appropriate TelegramEngine area file. Do not introduce any new wrapper structs. - -Run full build, expect PASS. Commit: - -```bash -git add submodules/TelegramCore/... -git commit -m "$(cat <<'EOF' -TelegramCore: add engine typealiases for - -Prepares for ChatInterfaceState to drop Postbox. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - -- [ ] **Step 3: Edit the consumer file** - -Apply the mappings from Step 2 to every reference. Remove the line `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ChatInterfaceState/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/ChatInterfaceState/Sources # expect: empty -grep "submodules/Postbox" submodules/ChatInterfaceState/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build command. Expected: PASS. Handle failures per the rules in Task 1 Step 6. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ChatInterfaceState/ -git commit -m "$(cat <<'EOF' -ChatInterfaceState: drop direct Postbox dependency - -Switch remaining Postbox-typed references to engine typealiases; -remove the Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 3: Refactor `ChatListSearchRecentPeersNode` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** The module's public `init` at line 207 takes `postbox: Postbox` as a parameter. Two out-of-wave callers (`submodules/ShareController/Sources/ShareControllerRecentPeersGridItem.swift`, `submodules/ChatListUI/Sources/ChatListRecentPeersListItem.swift`) use this init. Refactoring requires either typealiasing the `Postbox` class (banned by spec rule 2) or editing those two out-of-wave modules (banned by wave boundary). Per the abandonment protocol, the module is skipped. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift` -- Modify: `submodules/ChatListSearchRecentPeersNode/BUILD` - -**Starting inventory** (computed during planning): file uses `postbox.transaction { ... }` (×2), `postbox.combinedView(...)` (×1), and references `TelegramMedia*` types (×3). This is the **first hard module** in the wave — it has real imperative Postbox calls that require engine wrappers, not just typealiases. - -- [ ] **Step 1: Inventory** - -Read the whole file. For each Postbox call, capture: -- The call site (line number, containing function). -- The `PostboxViewKey`(s) passed to `combinedView`. -- What the closure body of each `transaction` does — the *intent*, not just the code. (This determines which engine method to add.) - -Run: - -```bash -grep -nE "\b(postbox\.|mediaBox|transaction\s*\{|combinedView|PostboxView|PostboxViewKey|Namespaces\.|TelegramMedia|PeerId|MessageId)\b" submodules/ChatListSearchRecentPeersNode/Sources/ChatListSearchRecentPeersNode.swift -``` - -- [ ] **Step 2: Map each reference** - -- `TelegramMedia*` — these classes are defined in `TelegramCore` (check `submodules/TelegramCore/Sources/`), not Postbox. After `import Postbox` is removed they remain reachable via `import TelegramCore`, which the file already imports. No action beyond confirming. -- Each `postbox.combinedView` / view subscription → map to an existing `TelegramEngine.data.subscribe(...)` item if one exists for the same `PostboxViewKey`; if not, add an `EngineData.Item` under `submodules/TelegramCore/Sources/TelegramEngine/Data/Data.swift`. -- Each `postbox.transaction { ... }` → a specific new method on the matching engine area (e.g. `TelegramEngine.Peers.recordRecentPeer(id:)` if that's what the closure does). Do **not** add a generic transaction passthrough. - -Write the mapping down before editing. Each new engine method is small and focused. - -- [ ] **Step 2a: Add engine wrappers in TelegramCore** - -For each new `EngineData.Item` or engine method identified in Step 2: - -- Add it to the appropriate file under `submodules/TelegramCore/Sources/TelegramEngine//` (or `…/Data/Data.swift` for data items). -- Keep the body to a minimal pass-through: the new engine method opens a transaction internally and calls the same Postbox code that the consumer was running; the new `EngineData.Item` forwards a `PostboxViewKey` in `keys()` and extracts its `PostboxView` in `extract()`. -- Return engine-typed values where existing engine types are available; otherwise return primitives or `Void`. Do not return bare Postbox types. - -Before editing TelegramCore, grep for existing wrappers covering the same need: - -```bash -grep -rn "\|" submodules/TelegramCore/Sources/TelegramEngine/ -``` - -Record "searched for X, found/not found" in the commit message. - -Run the full build. Expected: PASS. - -Commit: - -```bash -git add submodules/TelegramCore/... -git commit -m "$(cat <<'EOF' -TelegramCore: add - -Prepares for ChatListSearchRecentPeersNode to drop Postbox. -Searched TelegramEngine/ for existing equivalents: not found. - - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - -- [ ] **Step 3: Edit the consumer file** - -Replace each `postbox.transaction` and `postbox.combinedView` call with the engine method/subscription added in Step 2a. Swap any Postbox-typed names for engine typealiases per the cheat sheet. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ChatListSearchRecentPeersNode/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/ChatListSearchRecentPeersNode/Sources # expect: empty -grep "submodules/Postbox" submodules/ChatListSearchRecentPeersNode/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build command. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ChatListSearchRecentPeersNode/ -git commit -m "$(cat <<'EOF' -ChatListSearchRecentPeersNode: drop direct Postbox dependency - -Route combined-view subscription and transactions through -TelegramEngine; remove the Postbox import and Bazel dep. -Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 4: Refactor `ChatSendMessageActionUI` - -**Files:** -- Modify: `submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift` -- Modify: `submodules/ChatSendMessageActionUI/BUILD` - -**Starting inventory** (computed during planning): `mediaBox` (×2), `Peer` type reference (×1), `Media` type reference (×1), `MediaResource` (×1), `Namespaces.` (×1). The mediaBox calls are the substantive work. - -- [ ] **Step 1: Inventory** - -Read the whole file. Capture every `mediaBox` call — what resource is being asked for and what is done with the result (data read, status subscription, fetch start, path access)? Capture each Postbox-typed reference's line/context. - -```bash -grep -nE "\bmediaBox\b|\bNamespaces\.|\b(Peer|Media|MediaResource)\b" submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift -``` - -- [ ] **Step 2: Map each reference** - -- Each `mediaBox.` call → `engine.resources.(...)`. Check for an existing method on `TelegramEngine.Resources` first: - ```bash - grep -rn "extension.*Resources\|public func" submodules/TelegramCore/Sources/TelegramEngine/Resources/ - ``` - If an equivalent exists, use it. If not, add one in Step 2a. -- `Peer`, `Media`, `MediaResource` type references → use `EnginePeer`, `EngineMedia`, or `EngineMediaResource` (add typealias if missing, per the cheat sheet). -- `Namespaces.Peer.` — defined in TelegramCore, not Postbox. Confirm via grep; no change needed. - -- [ ] **Step 2a: (Only if needed) Add engine wrappers in TelegramCore** - -Per the rules — minimal pass-through, return engine-typed values. Build, commit `TelegramCore: add ` per the template in Task 3 Step 2a. - -- [ ] **Step 3: Edit the consumer file** - -Apply mappings. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ChatSendMessageActionUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/ChatSendMessageActionUI/Sources # expect: empty -grep "submodules/Postbox" submodules/ChatSendMessageActionUI/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ChatSendMessageActionUI/ -git commit -m "$(cat <<'EOF' -ChatSendMessageActionUI: drop direct Postbox dependency - -Route MediaBox calls through TelegramEngine.resources and switch -type references to engine typealiases; remove the Postbox import -and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 5: Refactor `ContactListUI` - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactListNode.swift` -- Modify: `submodules/ContactListUI/BUILD` - -**Starting inventory** (computed during planning): `postbox.transaction { ... }` (×4), `Peer` references (×15), `Namespaces.` (×1). Transactions are the substantive work; the 15 `Peer` references are likely in closures reading transaction state and will switch to engine-typed returns once the transactions are replaced. - -- [ ] **Step 1: Inventory** - -Read the whole file. For each `transaction` call, describe what the closure does — this drives what new engine methods to add. Capture every `Peer`-typed declaration. - -```bash -grep -nE "\btransaction\s*\{|account\.postbox|\b(Peer|PeerId|Namespaces)\b" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -- [ ] **Step 2: Map each reference** - -- Each `postbox.transaction { ... }` → a dedicated engine method under `submodules/TelegramCore/Sources/TelegramEngine/{Peers,Contacts,AccountData}/` capturing the closure's intent. Never add a generic transaction passthrough. -- `Peer` type → `EnginePeer` where the value actually flows through the replaced engine method (the engine method should return `EnginePeer` / `[EnginePeer]`). For local variable types that receive the engine-method return, use the engine type. -- `Namespaces.*` — defined in TelegramCore. No change. - -Before adding methods, grep for existing engine functions that may already cover the intent: - -```bash -grep -rn "public func" submodules/TelegramCore/Sources/TelegramEngine/Contacts/ -grep -rn "public func" submodules/TelegramCore/Sources/TelegramEngine/Peers/ | head -60 -``` - -- [ ] **Step 2a: Add engine wrappers in TelegramCore** - -Add each new method. Return engine-typed values. Build; then use the TelegramCore wrapper commit template from the Background section. - -- [ ] **Step 3: Edit the consumer file** - -Replace every `transaction` call with its engine method. Switch `Peer` locals to `EnginePeer`. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ContactListUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/ContactListUI/Sources # expect: empty -grep "submodules/Postbox" submodules/ContactListUI/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. ContactListUI is imported by other submodules (TelegramUI, SettingsUI, etc.); downstream breakage is most likely here. If a downstream consumer needs a bare `Peer`, either keep the public surface returning engine types (preferred — they're typealiases under the hood) or, if the downstream change is large, revert Task 5 and skip. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ContactListUI/ -git commit -m "$(cat <<'EOF' -ContactListUI: drop direct Postbox dependency - -Replace direct postbox.transaction calls with dedicated engine -methods; switch peer references to engine-typed equivalents; -remove the Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 6: Refactor `DirectMediaImageCache` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** The module's public `init(account: Account)` at line 241 takes `account: Account` (an umbrella type banned by spec rule 2). Out-of-wave callers include `submodules/CalendarMessageScreen/`, four TelegramUI components (`StoryContainerScreen`, `ShareWithPeersScreen`, `PeerInfoVisualMediaPaneNode` × 2), and `submodules/TelegramUI/Sources/AccountContext.swift`. Refactoring requires either aliasing `Account` (banned) or editing all those out-of-wave callers (banned). Per the abandonment protocol, the module is skipped. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift` -- Modify: `submodules/DirectMediaImageCache/BUILD` - -**Starting inventory** (computed during planning): `mediaBox` (×11), `PeerReference` (×6), `MediaResource` (×1), `TelegramMedia*` (×13), `Media`/`Message` type references. This module is **mediaBox-heavy** and is the canonical shape for the `engine.resources.*` extension work. - -- [ ] **Step 1: Inventory** - -Read the whole file. For each `mediaBox` call, record the method (`resourceData`, `resourceStatus`, `cachedResourceRepresentation`, `storeCachedResourceRepresentation`, `fetchedResource`, etc.) and whether it reads, writes, or subscribes. - -```bash -grep -nE "\bmediaBox\b|\b(PeerReference|MediaResource|TelegramMedia)" submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift -``` - -- [ ] **Step 2: Map each reference** - -Each distinct `mediaBox.` signature → a method on `TelegramEngine.Resources`. Expected additions (names are suggestions — match existing naming if anything close already exists): - -- `engine.resources.data(_:pathExtension:option:attemptSynchronously:) -> Signal` -- `engine.resources.status(_:approximateSynchronousValue:) -> Signal` -- `engine.resources.cachedRepresentationData(_:representation:complete:) -> Signal<...>` -- `engine.resources.storeCachedRepresentation(_:representation:data:) -> Signal` - -Before adding any of these, grep `submodules/TelegramCore/Sources/TelegramEngine/Resources/` for existing equivalents and only add what's missing. - -`PeerReference`, `TelegramMedia*`, `MediaResource` — check each: `TelegramMedia*` types live in `TelegramCore` (not Postbox). `PeerReference` lives in `TelegramCore`. `MediaResource` is a Postbox protocol; add `EngineMediaResource = MediaResource` typealias if not already present. - -- [ ] **Step 2a: Add engine wrappers in TelegramCore** - -Add all missing methods on `Resources` and any missing typealiases. Each method is a one-line forward to `account.postbox.mediaBox.*`. Build; then commit using the TelegramCore wrapper commit template from the Background section, recording "searched Resources/ for equivalents: found/not found" in the message. - -- [ ] **Step 3: Edit the consumer file** - -Replace every `mediaBox.*` with `engine.resources.*`. This likely requires adding an `engine: TelegramEngine` parameter to a few internal functions in the file (or surfacing it from an existing `AccountContext` already in scope — prefer that). Switch types to engine typealiases. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/DirectMediaImageCache/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/DirectMediaImageCache/Sources # expect: empty -grep "submodules/Postbox" submodules/DirectMediaImageCache/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/DirectMediaImageCache/ -git commit -m "$(cat <<'EOF' -DirectMediaImageCache: drop direct Postbox dependency - -Route MediaBox calls through TelegramEngine.resources; remove the -Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 7: Refactor `DrawingUI` - -**Files:** -- Modify: `submodules/DrawingUI/Sources/DrawingScreen.swift` -- Modify: `submodules/DrawingUI/BUILD` - -**Starting inventory** (computed during planning): `transaction` (×3), `Media` type references (×13), `Namespaces.` (×4). Transactions are the substantive work; `Media`/`Namespaces` are mostly referencing TelegramCore-defined types already. - -- [ ] **Step 1: Inventory** - -Read the whole file. For each `transaction` call, describe what the closure does. Capture every `Media`-typed declaration and every `Namespaces.*` reference. - -```bash -grep -nE "\btransaction\s*\{|account\.postbox|\b(Media|MediaId|Namespaces)\b" submodules/DrawingUI/Sources/DrawingScreen.swift -``` - -- [ ] **Step 2: Map each reference** - -- Each `postbox.transaction` → dedicated engine method. Inspect the closure to find the right home (`Stickers`, `Messages`, `Peers`, …). -- `Media` → `EngineMedia` where the value flows through new engine methods; keep as `Media` (TelegramCore re-defined concrete classes like `TelegramMediaFile` live in TelegramCore and are fine) where the type is already TelegramCore's. -- `Namespaces.*` — TelegramCore. No change. - -- [ ] **Step 2a: Add engine wrappers in TelegramCore** - -Add each new transaction-replacing method. Build; then use the TelegramCore wrapper commit template from the Background section. - -- [ ] **Step 3: Edit the consumer file** - -Apply mappings. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/DrawingUI/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/DrawingUI/Sources # expect: empty -grep "submodules/Postbox" submodules/DrawingUI/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/DrawingUI/ -git commit -m "$(cat <<'EOF' -DrawingUI: drop direct Postbox dependency - -Replace direct postbox.transaction calls with dedicated engine -methods; switch type references to engine equivalents; remove the -Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 8: Refactor `FetchManagerImpl` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** The module's public `init(postbox: Postbox, storeManager: DownloadedMediaStoreManager?)` at line 708 takes `postbox: Postbox`. Out-of-wave caller: `submodules/TelegramUI/Sources/AccountContext.swift:296`. Refactoring requires either aliasing the `Postbox` class (banned by spec rule 2) or editing TelegramUI (banned by wave boundary). Per the abandonment protocol, the module is skipped. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift` -- Modify: `submodules/FetchManagerImpl/BUILD` - -**Starting inventory** (computed during planning): `mediaBox` (×8), `MediaResource` (×4), `TelegramMedia` (×1). Shape is similar to DirectMediaImageCache — heavy `mediaBox` usage, no transactions. - -- [ ] **Step 1: Inventory** - -Read the whole file. For each `mediaBox` call, record the method and direction (read / subscribe / fetch-start). - -```bash -grep -nE "\bmediaBox\b|\b(MediaResource|TelegramMedia)\b" submodules/FetchManagerImpl/Sources/FetchManagerImpl.swift -``` - -- [ ] **Step 2: Map each reference** - -Reuse every `engine.resources.*` method added in Task 6 — do not re-add them. Grep: - -```bash -grep -rn "extension.*Resources\|public func" submodules/TelegramCore/Sources/TelegramEngine/Resources/ -``` - -If this task needs a `mediaBox` operation that Task 6 did not add (e.g. `cancelInteractiveResourceFetch`, `completeInteractiveResourceFetch`), add it now in the same pattern. - -`MediaResource` → `EngineMediaResource` typealias (already added in an earlier task if needed). `TelegramMedia` — TelegramCore type, no change. - -- [ ] **Step 2a: (Only if needed) Add missing engine methods** - -Minimal pass-through on `TelegramEngine.Resources`. Build, commit. - -- [ ] **Step 3: Edit the consumer file** - -Replace every `mediaBox.*` with `engine.resources.*`. Thread an `engine` argument through internal functions as needed (prefer reading it off an existing `AccountContext` already in scope). Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/FetchManagerImpl/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/FetchManagerImpl/Sources # expect: empty -grep "submodules/Postbox" submodules/FetchManagerImpl/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/FetchManagerImpl/ -git commit -m "$(cat <<'EOF' -FetchManagerImpl: drop direct Postbox dependency - -Route MediaBox fetch/status calls through TelegramEngine.resources; -remove the Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 9: Refactor `GalleryData` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** Four public functions take `Media` (Postbox protocol) and/or `Message` (Postbox class) as parameters, called from TelegramUI and ChatListUI (out-of-wave). Refactoring to `EngineMedia` / `EngineMessage` requires `.init(_:)` / `._asMedia()` / `._asMessage()` coercions threaded through many local variables (e.g. `var galleryMedia: Media?` in `chatMessageGalleryControllerData` is reassigned from various `TelegramMedia*` casts and passed to `MessageReference(...)` chains and enum cases), which would cascade into `AvatarGalleryEntry`, `MessageReference`, and other out-of-wave types. The narrow-utility alias path is ruled out because `Media` and especially `Message` are domain types, not utilities. Per the abandonment protocol, the module is skipped. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/GalleryData/Sources/GalleryData.swift` -- Modify: `submodules/GalleryData/BUILD` - -**Starting inventory** (computed during planning): `Peer` (×1), `Media` (×9), `Message` (×4), `Namespaces.` (×3), `TelegramMedia*` (×30). No `mediaBox`, no `transaction`, no `combinedView`. This is a **type-reference-only** case at scale. - -- [ ] **Step 1: Inventory** - -Read the whole file. Record every declaration that uses a Postbox-owned type (`Peer`, `Media`, `Message`, `MessageId`, etc.) — note that `TelegramMedia*` and `Namespaces` are TelegramCore, not Postbox, and do **not** need changing. - -```bash -grep -nE "\b(Peer|Media|Message|MessageId|MessageIndex)\b" submodules/GalleryData/Sources/GalleryData.swift | head -60 -``` - -- [ ] **Step 2: Map each reference** - -- `Peer` → `EnginePeer` at the call-site level where the value is newly produced; for existing public signatures that already accept a `Peer` from elsewhere, prefer `EnginePeer` **only if** downstream consumers accept it. Otherwise leave the signature alone and swap only the internal uses. -- `Media`, `Message`, `MessageId` → engine typealiases per the cheat sheet. -- `TelegramMedia*`, `Namespaces.*` — no change. - -- [ ] **Step 2a: (Only if needed) Add engine typealiases** - -Add any missing typealias in `submodules/TelegramCore/Sources/TelegramEngine/…`. Build, commit. - -- [ ] **Step 3: Edit the consumer file** - -Apply mappings. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/GalleryData/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/GalleryData/Sources # expect: empty -grep "submodules/Postbox" submodules/GalleryData/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/GalleryData/ -git commit -m "$(cat <<'EOF' -GalleryData: drop direct Postbox dependency - -Switch Postbox-typed references to engine typealiases; remove the -Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 10: Refactor `ICloudResources` — **ABANDONED** - -**Status:** Abandoned for wave 1. No code changes in this repo from this task. - -**Reason:** The module declares `public class ICloudFileResource: TelegramMediaResource` and thus must implement `func isEqual(to: MediaResource) -> Bool` (protocol requirement inherited from `MediaResource`). That override's parameter type is fixed at `MediaResource`, which can only be named by importing Postbox or adding a typealias for the raw `MediaResource` protocol. The protocol-alias would be borderline per rule 2; user directed to skip. Per the abandonment protocol, the module is skipped. - -**Original task body (retained for audit trail, do not implement):** - -### Original Task 10 - -**Files:** -- Modify: `submodules/ICloudResources/Sources/ICloudResources.swift` -- Modify: `submodules/ICloudResources/BUILD` - -**Starting inventory** (computed during planning): `MediaResource` (×2), `TelegramMedia` (×1). No `mediaBox`, no `transaction`. Small type-reference-only module. The `MediaResource` uses may be a custom `MediaResource`-conforming class defined in this file — confirm during inventory. - -- [ ] **Step 1: Inventory** - -Read the whole file. `MediaResource` is a Postbox protocol; `ICloudResources` likely declares a custom class conforming to it. Capture whether (a) the file declares new `MediaResource`-conforming types, (b) it only references the protocol, or both. - -```bash -grep -nE "\b(MediaResource|TelegramMedia)\b|class.*:.*MediaResource|struct.*:.*MediaResource" submodules/ICloudResources/Sources/ICloudResources.swift -``` - -- [ ] **Step 2: Map each reference** - -- If the file declares a type conforming to `MediaResource`, use the `EngineMediaResource` typealias in the declaration (`class FooResource: EngineMediaResource { ... }`). Because typealiases are transparent, this keeps protocol conformance identical. -- All other `MediaResource` references → `EngineMediaResource`. -- `TelegramMedia*` — TelegramCore, no change. - -Add `EngineMediaResource` typealias in TelegramCore if not already present (Task 2 / Task 6 may have added it; check first). - -- [ ] **Step 2a: (Only if needed) Add `EngineMediaResource` typealias** - -```swift -// submodules/TelegramCore/Sources/TelegramEngine/Resources/EngineMediaResource.swift -import Postbox -public typealias EngineMediaResource = MediaResource -``` - -Build, commit. - -- [ ] **Step 3: Edit the consumer file** - -Apply mappings. Remove `import Postbox`. - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/ICloudResources/BUILD`. Remove `"//submodules/Postbox:Postbox",` from `deps`. - -- [ ] **Step 5: Static checks** - -```bash -grep -R "^import Postbox" submodules/ICloudResources/Sources # expect: empty -grep "submodules/Postbox" submodules/ICloudResources/BUILD # expect: empty -``` - -- [ ] **Step 6: Full project build** - -Run the full build. Expected: PASS. Handle failures per the Build-failure handling rules in the Background section. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/ICloudResources/ -git commit -m "$(cat <<'EOF' -ICloudResources: drop direct Postbox dependency - -Switch MediaResource references to EngineMediaResource; remove the -Postbox import and Bazel dep. Behavior-preserving. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 11: Wave-1 completion verification - -**Files:** No code changes. - -- [ ] **Step 1: Static check across all 10 modules** - -```bash -for m in ActionSheetPeerItem ChatInterfaceState ChatListSearchRecentPeersNode ChatSendMessageActionUI ContactListUI DirectMediaImageCache DrawingUI FetchManagerImpl GalleryData ICloudResources; do - echo "=== $m ===" - grep -R "^import Postbox" submodules/$m/Sources && echo "FAIL: import in $m" - grep "submodules/Postbox" submodules/$m/BUILD && echo "FAIL: dep in $m" -done -``` - -Expected: no `FAIL` lines printed. If any appear, return to the corresponding task and fix. - -- [ ] **Step 2: Final full build** - -Run the full build one more time from a clean state. Expected: PASS. (If it passed at the end of Task 10 and nothing else has changed, this should be cached and fast.) - -- [ ] **Step 3: Review the commit log** - -```bash -git log --oneline master..HEAD -``` - -Expected: a run of commits matching the pattern `TelegramCore: add …` (optional) and `: drop direct Postbox dependency` (one per module done). If any module was skipped per the fallback rule, verify the fallback ran and a replacement module completed so the total is 10. - -- [ ] **Step 4: No commit** - -Verification only. - ---- - -## What's explicitly NOT in this plan - -- Any edits to `TelegramCore`, `Postbox`, or the 64 modules outside the chosen 10 (except the minimum engine-wrapper / typealias additions to `TelegramCore` that the chosen modules need). -- Any new `Engine*` wrapper *structs* (only typealiases and forwarding methods are in scope this wave). -- Any generic `engine.transaction { postbox in … }` escape hatch. -- Any behavior change, performance tweak, or "while we're here" cleanup. -- Any test work — there are no tests in this project. diff --git a/docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md b/docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md deleted file mode 100644 index e250e25aef..0000000000 --- a/docs/superpowers/plans/2026-04-17-listview-pin-to-edge.md +++ /dev/null @@ -1,223 +0,0 @@ -# ListView pin-to-edge Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement the first-pinned-item-to-bottom-edge behavior in `ListView` by adding a `calculatePinToEdgeTopInset()` helper and wiring it into `snapToBounds` and `updateScroller`, matching the design in [docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md](../specs/2026-04-17-listview-pin-to-edge-design.md). - -**Architecture:** Heights-based virtual-top-inset adjustment. A new private helper on `ListViewImpl` computes `max(0, visibleArea - ΣheightsAboveAndIncludingPinned)`. Two call sites add this to `effectiveInsets.top` via the existing `max(…)` chain alongside `stackFromBottomInsetItemFactor`. - -**Tech Stack:** Swift, ASDisplayKit, Bazel build system. - -**Scope:** Single file — `submodules/Display/Source/ListView.swift`. No protocol change (`pinToEdgeWithInset` is already declared on `ListViewItem`). No consumer changes. Because no item overrides `pinToEdgeWithInset` from its default `false`, the existing app surface's behavior is unchanged after this plan lands; the feature will be exercised only by a future consumer in a separate change. - -**No unit tests** exist in this project (per `CLAUDE.md`). Verification is via the full project build. - ---- - -## Task 1: Add `calculatePinToEdgeTopInset` helper and integrate at both call sites - -**Files:** -- Modify: `submodules/Display/Source/ListView.swift` - -The helper, both call-site edits, and the build verification land in one commit because they are tightly coupled: committing the helper without any call site is a no-op, and committing only one of the two call sites would cause `updateScroller` and `snapToBounds` to disagree about `effectiveInsets.top`, producing scroll-position desync whenever pinning is engaged. - ---- - -- [ ] **Step 1: Insert the `calculatePinToEdgeTopInset` helper after `calculateAdditionalTopInverseInset`** - -Use the Edit tool. The helper goes immediately after `calculateAdditionalTopInverseInset`'s closing brace (line 1090) and before `areAllItemsOnScreen` (line 1092). - -old_string: -```swift - private func calculateAdditionalTopInverseInset() -> CGFloat { - var additionalInverseTopInset: CGFloat = 0.0 - if !self.stackFromBottomInsetItemFactor.isZero { - var remainingFactor = self.stackFromBottomInsetItemFactor - for itemNode in self.itemNodes { - if remainingFactor.isLessThanOrEqualTo(0.0) { - break - } - - let itemFactor: CGFloat - if CGFloat(1.0).isLessThanOrEqualTo(remainingFactor) { - itemFactor = 1.0 - } else { - itemFactor = remainingFactor - } - - additionalInverseTopInset += floor(itemNode.apparentBounds.height * itemFactor) - - remainingFactor -= 1.0 - } - } - return additionalInverseTopInset - } - - private func areAllItemsOnScreen() -> Bool { -``` - -new_string: -```swift - private func calculateAdditionalTopInverseInset() -> CGFloat { - var additionalInverseTopInset: CGFloat = 0.0 - if !self.stackFromBottomInsetItemFactor.isZero { - var remainingFactor = self.stackFromBottomInsetItemFactor - for itemNode in self.itemNodes { - if remainingFactor.isLessThanOrEqualTo(0.0) { - break - } - - let itemFactor: CGFloat - if CGFloat(1.0).isLessThanOrEqualTo(remainingFactor) { - itemFactor = 1.0 - } else { - itemFactor = remainingFactor - } - - additionalInverseTopInset += floor(itemNode.apparentBounds.height * itemFactor) - - remainingFactor -= 1.0 - } - } - return additionalInverseTopInset - } - - private func calculatePinToEdgeTopInset() -> CGFloat { - var lowestPinnedIndex: Int = Int.max - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index < lowestPinnedIndex && self.items[index].pinToEdgeWithInset { - lowestPinnedIndex = index - } - } - guard lowestPinnedIndex != Int.max else { return 0.0 } - - var totalAboveAndPinned: CGFloat = 0.0 - var sawIndexZero = false - for itemNode in self.itemNodes { - guard let index = itemNode.index else { continue } - if index == 0 { - sawIndexZero = true - } - if index <= lowestPinnedIndex { - totalAboveAndPinned += itemNode.apparentBounds.height - } - } - guard sawIndexZero else { return 0.0 } - - let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom - return max(0.0, visibleArea - totalAboveAndPinned) - } - - private func areAllItemsOnScreen() -> Bool { -``` - -- [ ] **Step 2: Integrate at the `snapToBounds` call site** - -Use the Edit tool. The block at lines 1181-1185 in `snapToBounds` gets a new `pinToEdgeTopInset` stanza after the existing `stackFromBottomInsetItemFactor` branch. Include the following line (` ` + `if topItemFound {`) in the old_string to disambiguate from the structurally-identical block in `areAllItemsOnScreen` at line 1110. - -old_string: -```swift - var effectiveInsets = self.insets - if topItemFound && !self.stackFromBottomInsetItemFactor.isZero { - let additionalInverseTopInset = self.calculateAdditionalTopInverseInset() - effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset) - } - - if topItemFound { -``` - -new_string: -```swift - var effectiveInsets = self.insets - if topItemFound && !self.stackFromBottomInsetItemFactor.isZero { - let additionalInverseTopInset = self.calculateAdditionalTopInverseInset() - effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset) - } - let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() - if pinToEdgeTopInset > 0.0 { - effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset) - } - - if topItemFound { -``` - -- [ ] **Step 3: Integrate at the `updateScroller` call site** - -Use the Edit tool. The block at lines 1612-1616 in `updateScroller` is nested one extra level (12-space indent rather than 8-space), so the string alone is unique and the old_string doesn't need extra context. - -old_string: -```swift - var effectiveInsets = self.insets - if topItemFound && !self.stackFromBottomInsetItemFactor.isZero { - let additionalInverseTopInset = self.calculateAdditionalTopInverseInset() - effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset) - } - - completeHeight = effectiveInsets.top + effectiveInsets.bottom -``` - -new_string: -```swift - var effectiveInsets = self.insets - if topItemFound && !self.stackFromBottomInsetItemFactor.isZero { - let additionalInverseTopInset = self.calculateAdditionalTopInverseInset() - effectiveInsets.top = max(effectiveInsets.top, self.visibleSize.height - additionalInverseTopInset) - } - let pinToEdgeTopInset = self.calculatePinToEdgeTopInset() - if pinToEdgeTopInset > 0.0 { - effectiveInsets.top = max(effectiveInsets.top, self.insets.top + pinToEdgeTopInset) - } - - completeHeight = effectiveInsets.top + effectiveInsets.bottom -``` - -- [ ] **Step 4: Run the full project build** - -Use the Bash tool. The build takes several minutes; run it in the foreground so the agent waits for completion and surfaces failures immediately. The `source ~/.zshrc` prefix picks up `TELEGRAM_CODESIGNING_GIT_PASSWORD` per the build-environment quirk documented in `CLAUDE.md`. - -``` -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 -``` - -Expected: successful build. No warnings or errors touching `ListView.swift`. - -If the build fails: -- Swift syntax error → re-read `ListView.swift` around the edited regions; compare against the plan's old_string/new_string; fix and re-run. -- "`pinToEdgeWithInset` has no member" → the protocol property wasn't found; verify `submodules/Display/Source/ListViewItem.swift:80` still declares `var pinToEdgeWithInset: Bool { get }` and the default implementation at `ListViewItem.swift:102` is intact. If intact but the error persists, check that the `items` array's element type is `ListViewItem` (it is — see `public final var items: [ListViewItem]` in `ListView.swift`). -- Any other failure in unrelated files → not caused by this plan; investigate separately. - -- [ ] **Step 5: Commit** - -```bash -git add submodules/Display/Source/ListView.swift -git commit -m "$(cat <<'EOF' -Display/ListView: pin first pinToEdgeWithInset item to bottom edge - -Adds calculatePinToEdgeTopInset() and wires it into snapToBounds and -updateScroller. When the smallest-index item with pinToEdgeWithInset=true -plus all items above it have a combined apparentBounds height less than -the available scrolling area, the helper returns a positive top-inset -contribution that pushes the pinned item's maxY to visibleSize.height - -insets.bottom. Once items above reach the available area, the -contribution is zero and scrolling is fully ordinary. - -Spec: docs/superpowers/specs/2026-04-17-listview-pin-to-edge-design.md - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Verify with `git status` that the tree is clean after the commit. - ---- - -## Rationale for task granularity - -This plan has a single task. I considered splitting "add helper" from "apply at two call sites" into two commits: - -- **For splitting:** one commit per "unit of change" is more bisectable. -- **Against splitting:** the helper alone is unused (runtime no-op, and Swift does not warn on unused private methods). Applying at one call site without the other would produce a live bug — `snapToBounds` and `updateScroller` would disagree whenever pinning engages, and `updateScroller` is what sets `scroller.contentSize`/`contentOffset`. Three commits land an internally-consistent state only at the third commit. - -Bundling all edits preserves bisectability at the feature-level boundary (the commit either introduces pin-to-edge support or it doesn't) and keeps the repo free of intermediate broken states. diff --git a/docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md b/docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md deleted file mode 100644 index 94b5f43479..0000000000 --- a/docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md +++ /dev/null @@ -1,880 +0,0 @@ -# MediaResource → EngineMediaResource Refactor (Wave 2) — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drive raw `MediaResource` (Postbox protocol) out of the `TelegramEngine` public facade by changing facade-function signatures in-place to take/return `EngineMediaResource`, bridging to the existing `_internal_*` Postbox-facing implementations via wrap/unwrap helpers. In the same commit as each facade change, update every call site. Follow up with a first small batch of consumer type-reference migrations. - -**Architecture:** `TelegramEngine` facade methods live alongside `_internal_*` Postbox-using implementations in `submodules/TelegramCore/Sources/TelegramEngine//`. Today the facade methods already bridge (storing an `Account` and delegating), but their public signatures still expose raw `MediaResource`. The fix: change facade signatures to `EngineMediaResource` (including the `mapResourceToAvatarSizes` closure types) and add the two-line wrap/unwrap bridging. `_internal_*` functions stay on raw `MediaResource` — they are the Postbox-facing layer and must remain so. Consumer call sites swap `MediaResource` → `EngineMediaResource` (usually via `EngineMediaResource(raw)` wrap or `engineResource._asResource()` unwrap at a nearby boundary). - -**Tech Stack:** Swift, Bazel, Postbox (opaque storage), TelegramCore (public facade), SSignalKit. - -**Design constraint (IMPORTANT):** `TelegramCore` is shared with the Telegram-Mac codebase and must **not** import UIKit/Display. Any UIKit-requiring logic (image scaling, `UIImage`, `generateScaledImage`, etc.) stays in consumer-side submodules. Engine API additions must not pull in UIKit. - -**Why not overloads:** An earlier iteration of this plan added opt-in `EngineMediaResource` overloads and kept the raw overloads. That was rejected: duplicate signatures fragment the public API and leave raw-`MediaResource` leaks forever. The correct pattern is to change the single facade function in-place so it takes engine types and bridges inside, forcing callers to migrate in the same commit. - ---- - -## Background the executor needs - -### The full build command - -Run from the repo root (`/Users/ali/build/telegram/telegram-ios`): - -```bash -source ~/.zshrc 2>/dev/null; \ -PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH \ - python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development \ - --gitCodesigningUseCurrent \ - --buildNumber 1 \ - --configuration debug_sim_arm64 -``` - -The build is the only verification (no unit tests per `CLAUDE.md`). Every task ends with a full build that must go green before the next task starts. - -### What `EngineMediaResource` gives you today (bridge primitives) - -Defined in [TelegramEngineResources.swift](../../../submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift): - -```swift -public final class EngineMediaResource: Equatable { - public init(_ resource: MediaResource) - public func _asResource() -> MediaResource - public var id: Id - public struct Id: Equatable, Hashable { - public init(_ id: MediaResourceId) - public init(_ stringRepresentation: String) - } - public final class ResourceData { - public let path: String; public let availableSize: Int64; public let isComplete: Bool - } - public enum FetchStatus: Equatable { /* Remote/Local/Fetching/Paused */ } -} -public extension EngineMediaResource.ResourceData { - convenience init(_ data: MediaResourceData) -} -``` - -### The bridging pattern - -For each facade function whose public signature contains `MediaResource`: - -**Before** (raw-protocol leak): - -```swift -public func uploadedPeerPhoto(resource: MediaResource) -> Signal { - return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource) -} -``` - -**After** (engine-typed facade, internal bridge): - -```swift -public func uploadedPeerPhoto(resource: EngineMediaResource) -> Signal { - return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource._asResource()) -} -``` - -For closures that receive a `MediaResource`: - -**Before:** - -```swift -public func updatePeerPhoto(..., mapResourceToAvatarSizes: @escaping (MediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> ... { - return _internal_updatePeerPhoto(..., mapResourceToAvatarSizes: mapResourceToAvatarSizes) -} -``` - -**After:** - -```swift -public func updatePeerPhoto(..., mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> ... { - return _internal_updatePeerPhoto(..., mapResourceToAvatarSizes: { rawResource, representations in - mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) - }) -} -``` - -`_internal_*` functions are **not** changed — they stay on raw `MediaResource` as the Postbox-facing layer. - -### Call-site migration pattern - -At each call site, the change is mechanical: - -- `engine.peers.uploadedPeerPhoto(resource: someRawResource)` → `engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(someRawResource))`. -- `engine.peers.updatePeerPhoto(..., mapResourceToAvatarSizes: { resource, representations in ... resource ... })` — the closure's `resource` is now `EngineMediaResource`. Any expression inside the closure that previously treated `resource` as raw protocol (e.g. `postbox.mediaBox.resourceData(resource)`) must use `resource._asResource()`. - -Where the consumer was carrying a `MediaResource?` property / local purely as a pipe into one of these APIs, migrate the property itself to `EngineMediaResource?` so no unwrap/wrap churn is needed. - -### Static-check commands - -```bash -grep -R "^import Postbox" submodules//Sources # expect: empty (only when a module is being fully de-Postboxed) -grep "submodules/Postbox" submodules//BUILD # expect: empty (same condition) -``` - -### Commit convention - -- One commit per engine API family: `TelegramCore: migrate to EngineMediaResource` — bundles facade-signature change **and** all call sites updated in the same commit. The repo must build on every commit. -- Consumer-only type-ref commits: `: migrate MediaResource property to EngineMediaResource` or `: drop direct Postbox dependency`. -- Always use HEREDOC bodies. No `--amend`. - -### What is explicitly out of scope - -- Classes that **conform to `TelegramMediaResource`** (must implement `isEqual(to: MediaResource)`): remain `import Postbox`. Enumerated: - - `submodules/ICloudResources/Sources/ICloudResources.swift` — `ICloudFileResource` - - `submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift` — `InstantPageExternalMediaResource` - - `submodules/LocalMediaResources/Sources/LocalMediaResources.swift` — `VideoLibraryMediaResource` - - `submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift` — `YoutubeEmbedStoryboardMediaResource` -- TelegramCore-internal `MediaResource` usage (SyncCore, Fetch, `_internal_*` functions, etc.) — Postbox-facing layer. -- Modules already abandoned in wave 1 for non-MediaResource reasons (`FetchManagerImpl` / `ICloudResources` have other umbrella-type blockers). -- The heavy-leak modules in the "Future waves" table at the bottom (`PassportUI`, `TelegramUI`, etc.). -- Importing UIKit/Display into TelegramCore under any circumstance. - ---- - -## Task 0: Baseline verification - -**Files:** No code changes. - -- [ ] **Step 1: Confirm tree state** - -```bash -git status -git log --oneline -5 -``` - -Expected: working tree clean apart from pre-existing untracked (`build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/`) and submodule-content drift on `build-system/bazel-rules/sourcekit-bazel-bsp`. HEAD on `master`. - -- [ ] **Step 2: Baseline build** - -Run the full build command above. Expected: PASS. - -If it fails, stop — a non-green baseline is out of scope. - -- [ ] **Step 3: No commit.** - ---- - -## Task 1: Record the new rules in CLAUDE.md - -**Files:** -- Modify: `CLAUDE.md` - -- [ ] **Step 1: Add the "TelegramCore no UIKit" rule** - -In `CLAUDE.md`, inside the `## Postbox → TelegramEngine refactor (in progress)` section, under `### Rules that apply to every wave`, append a new numbered rule after the existing rule 6: - -```markdown -7. **TelegramCore never imports UIKit/Display.** `TelegramCore` is shared with the Telegram-Mac codebase; its Bazel `deps` and source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules. -``` - -- [ ] **Step 2: Add the MediaResource → EngineMediaResource migration pattern** - -After the `### Engine typealias cheat sheet (existing aliases)` block (which ends with the `MediaResource` / `TelegramMediaResource` note), insert a new section: - -```markdown -### MediaResource → EngineMediaResource consumer migration - -`EngineMediaResource` is a `final class` in `TelegramCore` wrapping a `MediaResource` value. Unlike the typealiases above it is **not** interchangeable with the protocol, but it does provide wrap/unwrap helpers: - -- `EngineMediaResource(rawResource)` — wrap a raw `MediaResource`. -- `engineResource._asResource()` — unwrap to the raw `MediaResource`. -- `EngineMediaResource.ResourceData(rawResourceData)` — wrap `MediaResourceData`. -- `EngineMediaResource.Id(rawMediaResourceId)` — wrap `MediaResourceId`. - -**Pattern for facade functions:** when a `TelegramEngine.` method leaks raw `MediaResource` in its public signature, **change the facade signature in place** to `EngineMediaResource` (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing `_internal_*` function with `engineResource._asResource()` / wrapping raw inputs from inner closures with `EngineMediaResource(rawResource)`. Update all call sites in the same commit. The `_internal_*` function stays on raw `MediaResource` — it is the Postbox-facing layer. - -Do **not** add opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. Duplicate signatures fragment the public API and leave the leak in place forever. - -For consumer modules, prefer `EngineMediaResource` as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do **not** try to use `EngineMediaResource` where a class must conform to `TelegramMediaResource` (Postbox protocol) or override `isEqual(to: MediaResource)` — those remain `import Postbox`. -``` - -- [ ] **Step 3: Full build (sanity — docs only)** - -Run the full build. Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -CLAUDE.md: record TelegramCore-no-UIKit rule and EngineMediaResource migration pattern - -Wave-2 preparation. Codifies that TelegramCore is shared with -Telegram-Mac and must stay UIKit-free, and documents the -modify-in-place / bridge-inside pattern for migrating -MediaResource-leaking facade functions to EngineMediaResource. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 2: Migrate `TelegramEngine.Peers` photo APIs to `EngineMediaResource` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift` -- Modify: all call sites (13 + 11 + 7, with heavy overlap — see Step 2 grep). - -**Functions migrated in this task:** -- `uploadedPeerPhoto(resource:)` (line 704) — `MediaResource` → `EngineMediaResource` -- `uploadedPeerVideo(resource:)` (line 708) — `MediaResource` → `EngineMediaResource` -- `updatePeerPhoto(..., mapResourceToAvatarSizes:)` (line 712) — closure parameter `MediaResource` → `EngineMediaResource` - -- [ ] **Step 1: Read the current signatures** - -Read lines 704–720 of `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift`. Confirm the three functions match the pattern `return _internal_(postbox: self.account.postbox, ..., resource: resource)` or equivalent. - -- [ ] **Step 2: Enumerate call sites** - -```bash -grep -rnE "\\.(uploadedPeerPhoto|uploadedPeerVideo|updatePeerPhoto)\(" submodules/ \ - | grep -v "submodules/TelegramCore" -``` - -Capture every hit — file path, line number, approximate surrounding context (what resource expression is passed in / what the closure body does). The distribution as of planning: - -- `uploadedPeerPhoto`: 11 call sites (spread across TelegramUI, TelegramCallsUI, AuthorizationUI, etc.) -- `uploadedPeerVideo`: 7 -- `updatePeerPhoto`: 13 - -Many call sites chain these (e.g. `updatePeerPhoto(photo: engine.peers.uploadedPeerPhoto(resource: ...))`) so a single file often touches two or three of them in one call. - -- [ ] **Step 3: Change the facade signatures + bridge** - -In `TelegramEnginePeers.swift`, change the three functions to: - -```swift -public func uploadedPeerPhoto(resource: EngineMediaResource) -> Signal { - return _internal_uploadedPeerPhoto(postbox: self.account.postbox, network: self.account.network, resource: resource._asResource()) -} - -public func uploadedPeerVideo(resource: EngineMediaResource) -> Signal { - return _internal_uploadedPeerVideo(postbox: self.account.postbox, network: self.account.network, messageMediaPreuploadManager: self.account.messageMediaPreuploadManager, resource: resource._asResource()) -} - -public func updatePeerPhoto(peerId: PeerId, photo: Signal?, video: Signal? = nil, videoStartTimestamp: Double? = nil, markup: UploadPeerPhotoMarkup? = nil, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal { - return _internal_updatePeerPhoto(postbox: self.account.postbox, network: self.account.network, stateManager: self.account.stateManager, accountPeerId: self.account.peerId, peerId: peerId, photo: photo, video: video, videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in - return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) - }) -} -``` - -**Before editing, re-read the existing bodies** — the exact arg names passed into `_internal_updatePeerPhoto` etc. must match what's already there (the skeletons above reproduce what's in the file at planning time, but the executor should preserve every argument the current implementation passes). Only the outer signature and the closure-wrapping change. - -- [ ] **Step 4: Update every call site** (same commit) - -For each hit from Step 2, rewrite the call site per the patterns: - -**Pattern A — passing a raw resource to `uploadedPeerPhoto` / `uploadedPeerVideo`:** - -```swift -// Before: -engine.peers.uploadedPeerPhoto(resource: someRawResource) -// After: -engine.peers.uploadedPeerPhoto(resource: EngineMediaResource(someRawResource)) -``` - -**Pattern B — the `mapResourceToAvatarSizes` closure of `updatePeerPhoto`:** - -```swift -// Before: -mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) -} -// After (if the helper is still raw-MediaResource-facing at this point): -mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource._asResource(), representations: representations) -} -``` - -Task 6 will change `mapResourceToAvatarSizes` itself to accept `EngineMediaResource` and drop the `_asResource()` call. Until Task 6 lands, keep the `_asResource()` here. This keeps the build green between tasks. - -**Pattern C — the consumer was already carrying the resource as a `MediaResource?` local purely as a pipe:** - -If a nearby local/property typed `MediaResource?` only exists to feed `uploadedPeerPhoto(resource:)` or similar, change the local's type to `EngineMediaResource?` at the same time. This avoids wrap/unwrap churn at the call site. - -- [ ] **Step 5: Full build** - -Run the full build. Expected: PASS. - -If it fails, the first error locates the broken call site. Apply Pattern A / B / C at that site and rebuild. If a file imports Postbox only for `MediaResource` and now has no other Postbox identifier, you may optionally remove `import Postbox` in the same commit — but that is not required here; it is a separate goal. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift submodules/ -git commit -m "$(cat <<'EOF' -TelegramCore: migrate peer-photo facade to EngineMediaResource - -Change TelegramEngine.Peers.uploadedPeerPhoto / uploadedPeerVideo / -updatePeerPhoto so their public signatures take EngineMediaResource -instead of raw MediaResource (and the mapResourceToAvatarSizes closure -receives EngineMediaResource). The facade bridges to the existing -_internal_* Postbox-facing implementations via _asResource() / -EngineMediaResource(_:). All call sites updated in this commit. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). -No behavior change. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 3: Migrate `TelegramEngine.AccountData.updateAccountPhoto` and `updateFallbackPhoto` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift` -- Modify: all call sites (5 + 4). - -- [ ] **Step 1: Read the current signatures** - -Read lines 55–90 of `submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift`. Confirm both functions match the expected pattern. - -- [ ] **Step 2: Enumerate call sites** - -```bash -grep -rnE "\\.(updateAccountPhoto|updateFallbackPhoto)\(" submodules/ \ - | grep -v "submodules/TelegramCore" -``` - -- [ ] **Step 3: Change the facade signatures + bridge** - -Change both functions in place: - -```swift -public func updateAccountPhoto(resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal { - return _internal_updateAccountPhoto(account: self.account, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in - return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) - }) -} - -public func updateFallbackPhoto(resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal { - return _internal_updateFallbackPhoto(account: self.account, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mapResourceToAvatarSizes: { rawResource, representations in - return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) - }) -} -``` - -**Before editing, verify the exact argument names passed to `_internal_updateAccountPhoto` / `_internal_updateFallbackPhoto`** in the current file. Copy those argument spellings verbatim (only the outer signature and inner closure wrapping change). - -- [ ] **Step 4: Update every call site** (same commit) - -Apply Pattern A/B/C from Task 2 to every hit. Wrap `EngineMediaResource(...)` around raw-resource args; add `._asResource()` inside any `mapResourceToAvatarSizes:` closure body where it hands the value onward to a still-raw helper (removed in Task 6). - -- [ ] **Step 5: Full build** - -Run the full build. Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/AccountData/TelegramEngineAccountData.swift submodules/ -git commit -m "$(cat <<'EOF' -TelegramCore: migrate account-photo facade to EngineMediaResource - -Change TelegramEngine.AccountData.updateAccountPhoto and -updateFallbackPhoto so their public signatures take EngineMediaResource -(and the mapResourceToAvatarSizes closure receives -EngineMediaResource). Bridges to _internal_* functions via -_asResource()/EngineMediaResource(_:). All call sites updated in this -commit. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). -No behavior change. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 4: Migrate `TelegramEngine.Contacts.updateContactPhoto` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift` -- Modify: all call sites (8). - -- [ ] **Step 1: Read the current signature** - -Read around line 33 of `submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift`. - -- [ ] **Step 2: Enumerate call sites** - -```bash -grep -rn "\.updateContactPhoto(" submodules/ | grep -v "submodules/TelegramCore" -``` - -- [ ] **Step 3: Change the facade signature + bridge** - -```swift -public func updateContactPhoto(peerId: PeerId, resource: EngineMediaResource?, videoResource: EngineMediaResource?, videoStartTimestamp: Double?, markup: UploadPeerPhotoMarkup?, mode: SetCustomPeerPhotoMode, mapResourceToAvatarSizes: @escaping (EngineMediaResource, [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>) -> Signal { - return _internal_updateContactPhoto(account: self.account, peerId: peerId, resource: resource?._asResource(), videoResource: videoResource?._asResource(), videoStartTimestamp: videoStartTimestamp, markup: markup, mode: mode, mapResourceToAvatarSizes: { rawResource, representations in - return mapResourceToAvatarSizes(EngineMediaResource(rawResource), representations) - }) -} -``` - -Verify the `_internal_updateContactPhoto` call spelling against the existing file before committing. - -- [ ] **Step 4: Update every call site** (same commit) - -Pattern A/B/C as in Task 2. - -- [ ] **Step 5: Full build** - -Run the full build. Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Contacts/TelegramEngineContacts.swift submodules/ -git commit -m "$(cat <<'EOF' -TelegramCore: migrate updateContactPhoto facade to EngineMediaResource - -Change TelegramEngine.Contacts.updateContactPhoto so its public -signature takes EngineMediaResource parameters and the -mapResourceToAvatarSizes closure receives EngineMediaResource. Bridges -to _internal_updateContactPhoto via _asResource()/EngineMediaResource(_:). -All call sites updated in this commit. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). -No behavior change. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 5: Migrate `TelegramEngine.Auth.uploadedPeerVideo` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift` -- Modify: call sites that route through `TelegramEngine.Auth.uploadedPeerVideo` (separate from `TelegramEngine.Peers.uploadedPeerVideo`). - -- [ ] **Step 1: Read the current signature** - -Read around line 51 of `submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift`. - -- [ ] **Step 2: Enumerate call sites** - -```bash -grep -rn "engine\.auth\.uploadedPeerVideo\|\.auth\.uploadedPeerVideo" submodules/ | grep -v "submodules/TelegramCore" -``` - -The call site count is small (the sign-up flow). If zero, skip Step 4. - -- [ ] **Step 3: Change the facade signature + bridge** - -```swift -public func uploadedPeerVideo(resource: EngineMediaResource) -> Signal { - return _internal_uploadedPeerVideo(postbox: self.account.postbox, network: self.account.network, messageMediaPreuploadManager: self.account.messageMediaPreuploadManager, resource: resource._asResource()) -} -``` - -Preserve the exact argument spellings from the existing function body. - -- [ ] **Step 4: Update call sites** (same commit) - -Pattern A. - -- [ ] **Step 5: Full build** - -Run the full build. Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Auth/TelegramEngineAuth.swift submodules/ -git commit -m "$(cat <<'EOF' -TelegramCore: migrate Auth.uploadedPeerVideo facade to EngineMediaResource - -Signature change + call sites. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). -No behavior change. - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 6: Migrate `mapResourceToAvatarSizes` utility and drop `import Postbox` from `MapResourceToAvatarSizes` - -**Files:** -- Modify: `submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift` -- Modify: `submodules/MapResourceToAvatarSizes/BUILD` -- Modify: all 27 call sites of the old `mapResourceToAvatarSizes(postbox:resource:representations:)`. - -**Preconditions:** Tasks 2–5 have landed, so every `mapResourceToAvatarSizes:` closure at call sites now receives an `EngineMediaResource` (because the facade closures were retyped). At this point the inner `mapResourceToAvatarSizes(postbox: …, resource: …._asResource(), …)` unwrap becomes avoidable. - -- [ ] **Step 1: Read the current file** - -``` -submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift -``` - -Confirm the function body uses `postbox.mediaBox.resourceData(resource)` and requires `UIImage` / `generateScaledImage` / `jpegData(compressionQuality:)`. - -- [ ] **Step 2: Enumerate call sites** - -```bash -grep -rn "mapResourceToAvatarSizes(postbox:" submodules/ | grep -v "submodules/MapResourceToAvatarSizes" -``` - -Expected: 27 call sites, concentrated in `submodules/TelegramUI/...PeerInfoScreenAvatarSetup.swift` (19), `TelegramCallsUI/...VideoChatScreenParticipantContextMenu.swift` (5), and three other TelegramUI files (1 each). - -- [ ] **Step 3: Rewrite the function to use `EngineMediaResource` + `TelegramEngine.Resources.data`** - -Replace the body of `submodules/MapResourceToAvatarSizes/Sources/MapResourceToAvatarSizes.swift` with: - -```swift -import Foundation -import UIKit -import SwiftSignalKit -import TelegramCore -import Display - -public func mapResourceToAvatarSizes(engine: TelegramEngine, resource: EngineMediaResource, representations: [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError> { - return engine.resources.data(id: resource.id) - |> take(1) - |> map { data -> [Int: Data] in - guard data.isComplete, let image = UIImage(contentsOfFile: data.path) else { - return [:] - } - var result: [Int: Data] = [:] - for i in 0 ..< representations.count { - let size: CGSize - if representations[i].dimensions.width == 80 { - size = CGSize(width: 160.0, height: 160.0) - } else { - size = representations[i].dimensions.cgSize - } - if let scaledImage = generateScaledImage(image: image, size: size, scale: 1.0), let scaledData = scaledImage.jpegData(compressionQuality: 0.8) { - result[i] = scaledData - } - } - return result - } -} -``` - -Notes: -- Signature: `(engine: TelegramEngine, resource: EngineMediaResource, representations: [TelegramMediaImageRepresentation]) -> Signal<[Int: Data], NoError>`. -- `import Postbox` is gone; replaced usage with `engine.resources.data(id:)` which returns `Signal`. -- `data.complete` → `data.isComplete` (field rename on the engine wrapper). - -- [ ] **Step 4: Drop the Bazel dep** - -Edit `submodules/MapResourceToAvatarSizes/BUILD` and remove `"//submodules/Postbox:Postbox",` from `deps`. Leave the rest untouched. - -- [ ] **Step 5: Update every call site** (same commit) - -At each of the 27 sites, two changes: - -**Pattern D — the call site already lives inside a `mapResourceToAvatarSizes:` closure on a facade function (post-Task-2/3/4, the closure's `resource` parameter is now `EngineMediaResource`):** - -```swift -// Before (from an intermediate state between tasks): -mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource._asResource(), representations: representations) -} -// After: -mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(engine: engine, resource: resource, representations: representations) -} -``` - -The `engine` value is always reachable at the call site — it's either a stored reference used right above the closure or `context.engine` / `accountContext.engine`. Grep shows every current call site has a `postbox = context.account.postbox` (or similar) just above, so `context.engine` / the adjacent engine reference is in scope. - -**Pattern E — direct (non-closure) call with a raw `MediaResource` in scope:** - -Rare in the current code, but if you find one, wrap with `EngineMediaResource(rawResource)` at the call. - -- [ ] **Step 6: Static checks** - -```bash -grep -R "^import Postbox" submodules/MapResourceToAvatarSizes/Sources # expect: empty -grep "submodules/Postbox" submodules/MapResourceToAvatarSizes/BUILD # expect: empty -``` - -- [ ] **Step 7: Full build** - -Run the full build. Expected: PASS. - -Likely failure modes: -- A call site's surrounding scope doesn't have an `engine` in context. Fix: use `.engine` or promote `engine` to a nearby `let`. -- A consumer file passed a non-`EngineMediaResource` into the closure because it wasn't updated by Task 2/3/4. Fix forward (update it now) and record the miss. - -- [ ] **Step 8: Commit** - -```bash -git add submodules/MapResourceToAvatarSizes/ submodules/TelegramUI/ submodules/TelegramCallsUI/ -git commit -m "$(cat <<'EOF' -MapResourceToAvatarSizes: migrate to EngineMediaResource and drop Postbox - -Change the signature of mapResourceToAvatarSizes from -(postbox: Postbox, resource: MediaResource, ...) to -(engine: TelegramEngine, resource: EngineMediaResource, ...), using -engine.resources.data(id:) internally. All 27 call sites updated in -this commit. `import Postbox` and the Bazel dep are removed. -Behavior-preserving. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 7: Migrate `AuthorizationUI` signal type - -**Files:** -- Modify: `submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift` - -**Starting inventory:** exactly one reference — `Signal` at line 1162. AuthorizationUI has six files importing Postbox overall; dropping `import Postbox` from the module as a whole is **not** in scope for this task. - -- [ ] **Step 1: Read line 1162 ± 20** - -Understand: -- What value is put into the signal? Likely some TelegramMediaResource subclass (e.g. `LocalFileMediaResource`). -- Who consumes the signal downstream? After Tasks 2–5, any facade that ultimately receives this signal's value (via `updateAccountPhoto`, `uploadedPeerVideo`, etc.) expects `EngineMediaResource`. - -- [ ] **Step 2: Change the signal type** - -```swift -// Before: -avatarVideo = Signal { subscriber in - // ... produces a TelegramMediaResource ... - subscriber.putNext(someResource) -} -// After: -avatarVideo = Signal { subscriber in - // ... produces a TelegramMediaResource ... - subscriber.putNext(someResource.flatMap { EngineMediaResource($0) }) // or wrap the non-optional path -} -``` - -The exact wrapping site depends on where the raw resource flows in. The grep + read from Step 1 tells you. - -Downstream, any call site that consumed the raw resource and handed it to an engine facade now has an `EngineMediaResource?` which it can pass directly (post-Tasks 2–5). - -- [ ] **Step 3: Full build** - -Run the full build. Expected: PASS. - -If the downstream expected a `TelegramMediaResource?` (e.g. for direct Postbox access that wasn't part of Tasks 2–5), revert this task as `Abandoned — downstream expects raw protocol` with a recorded reason. - -- [ ] **Step 4: Commit** - -```bash -git add submodules/AuthorizationUI/Sources/AuthorizationSequenceController.swift -git commit -m "$(cat <<'EOF' -AuthorizationUI: migrate avatar-video signal type to EngineMediaResource - -Single type-reference swap. Downstream engine facades already accept -EngineMediaResource after the Phase-1 migrations. Behavior-preserving. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 8: Migrate `SaveToCameraRoll` property type — **ABANDONED** - -**Status:** Abandoned in wave 2. No code changes from this task. - -**Reason:** The planning-time grep that produced the "one reference" inventory only matched `MediaResource`/`TelegramMediaResource` tokens, not the broader set of Postbox usages. Re-inventorying the module at execution time (`grep -nE "\b(postbox|mediaBox|MediaResource)\b|^import Postbox" submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`) shows three public functions with `postbox: Postbox` in their signatures (`fetchMediaData`, `saveToCameraRoll`, `copyToPasteboard`) plus multiple `postbox.mediaBox.*` calls in their bodies. Per spec rule 2, `Postbox` is an umbrella type that cannot be typealiased, so those public-API signatures cannot be de-Postboxed without editing every caller; and the internal `postbox.mediaBox.*` calls require engine-side wrappers (closer to Task 6's shape) rather than a simple type swap. Scope is a full module-migration wave, not a single type swap — parked for a future wave. - -**Original task body (retained for audit trail, do not implement):** - -**Files:** -- Modify: `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift` -- Possibly modify: `submodules/SaveToCameraRoll/BUILD` - -**Starting inventory:** one reference — `var resource: MediaResource?` at line 19. - -- [ ] **Step 1: Read + full grep** - -```bash -grep -nE "\b(MediaResource|TelegramMediaResource|postbox|mediaBox|transaction|PostboxView|combinedView)\b|^import Postbox" submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift -``` - -Capture every hit. - -- [ ] **Step 2: Abandon-check** - -If the grep shows Postbox usages other than the single `MediaResource?` property and an `import Postbox` line, abandon this task with a recorded reason. Do not substitute. - -If it shows only the property + import, proceed. - -- [ ] **Step 3: Swap the property type + boundary wrap/unwrap** - -Change `var resource: MediaResource?` to `var resource: EngineMediaResource?`. At each assignment/use: - -- Assignment from a raw resource: `self.resource = EngineMediaResource(rawResource)`; `self.resource = nil` unchanged. -- Read that hands to mediaBox/postbox (if any remains): `self.resource?._asResource()`. - -- [ ] **Step 4: Drop `import Postbox` if now unused** - -If Step 1 showed `import Postbox` as the only remaining Postbox reference: - -- Remove the `import Postbox` line. -- Remove `"//submodules/Postbox:Postbox",` from `submodules/SaveToCameraRoll/BUILD`. - -Static checks: - -```bash -grep -R "^import Postbox" submodules/SaveToCameraRoll/Sources # expect: empty -grep "submodules/Postbox" submodules/SaveToCameraRoll/BUILD # expect: empty -``` - -Else skip this step. - -- [ ] **Step 5: Full build** - -Run the full build. Expected: PASS. - -- [ ] **Step 6: Commit** - -If the import was removed: - -```bash -git add submodules/SaveToCameraRoll/ -git commit -m "$(cat <<'EOF' -SaveToCameraRoll: migrate resource property to EngineMediaResource and drop Postbox - -Swaps the single MediaResource? property for EngineMediaResource?, -wrapping/unwrapping at boundaries. With the only Postbox reference -gone, removes `import Postbox` and the Bazel dep. -Behavior-preserving. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - -If the import was kept: - -```bash -git add submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift -git commit -m "$(cat <<'EOF' -SaveToCameraRoll: migrate resource property to EngineMediaResource - -Swaps the single MediaResource? property for EngineMediaResource?, -wrapping/unwrapping at boundaries. import Postbox remains because -other identifiers still need it. Behavior-preserving. - -Part of the MediaResource -> EngineMediaResource migration (wave 2). - -Co-Authored-By: Claude Opus 4.6 -EOF -)" -``` - ---- - -## Task 9: Wave-2 completion verification - -**Files:** No code changes. - -- [ ] **Step 1: Commit-log check** - -```bash -git log --oneline master..HEAD # or whatever branch this was executed on -``` - -Expected commits (some may be absent if tasks abandoned): - -- `CLAUDE.md: record TelegramCore-no-UIKit rule and EngineMediaResource migration pattern` -- `TelegramCore: migrate peer-photo facade to EngineMediaResource` -- `TelegramCore: migrate account-photo facade to EngineMediaResource` -- `TelegramCore: migrate updateContactPhoto facade to EngineMediaResource` -- `TelegramCore: migrate Auth.uploadedPeerVideo facade to EngineMediaResource` -- `MapResourceToAvatarSizes: migrate to EngineMediaResource and drop Postbox` -- `AuthorizationUI: migrate avatar-video signal type to EngineMediaResource` -- `SaveToCameraRoll: migrate resource property to EngineMediaResource[...]` - -- [ ] **Step 2: Public-API leak check** - -```bash -grep -nE "^\s*public func .*: MediaResource|public func .*MediaResource, \[" \ - submodules/TelegramCore/Sources/TelegramEngine/ -``` - -Expected: no matches in the facade files touched by Tasks 2–5 (`TelegramEngine/Peers/TelegramEnginePeers.swift`, `TelegramEngine/AccountData/TelegramEngineAccountData.swift`, `TelegramEngine/Contacts/TelegramEngineContacts.swift`, `TelegramEngine/Auth/TelegramEngineAuth.swift`). Other TelegramEngine files may still leak `MediaResource` — those are for future waves. - -- [ ] **Step 3: Final full build from clean state** - -Run the full build. Expected: PASS (cached, fast). - -- [ ] **Step 4: No commit.** Verification only. - ---- - -## Future waves (not in this plan) - -Ranked consumer modules by MediaResource/TelegramMediaResource reference count (from `grep -rE "\\b(MediaResource|TelegramMediaResource)\\b"` over `submodules//Sources/`, excluding TelegramCore/Postbox). Classifications are preliminary and must be re-audited at the start of each future wave. - -| Refs | Module | Future-wave notes | -| --- | --- | --- | -| 2 | ChatPresentationInterfaceState | Public struct field `resource: TelegramMediaResource` — needs caller audit. | -| 2 | ItemListStickerPackItem | Enum case leaks `MediaResource` — needs caller audit. | -| 2 | TelegramCallsUI | Signal locals; mostly type-refs. | -| 3 | LegacyMediaPickerUI | `thumbnailResource: TelegramMediaResource?` internal properties — likely safe. | -| 3 | ReactionSelectionNode | `customEffectResource: MediaResource?` in public func — caller audit. | -| 3 | TelegramAnimatedStickerNode | `public init(postbox: Postbox, resource: MediaResource, …)` + `public convenience init(account: Account, …)` — umbrella-type leaks; needs a paired wave. | -| 4 | GalleryUI | `private func setupStatus(resource: MediaResource)` — internal, 4 files. | -| 5 | StickerResources | Multiple public funcs take `postbox: Postbox, resource: MediaResource` / `mediaBox: MediaBox`. | -| 6 | PhotoResources | Similar to StickerResources; also `securePhoto(account: Account, resource: TelegramMediaResource, …)`. | -| 7 | MediaPlayer | `mediaBox: MediaBox, resource: MediaResource` in public init — umbrella leaks. | -| 7 | WebSearchUI | `thumbnailResource: TelegramMediaResource?` in multiple structs/inits. | -| 8 | AccountContext | Protocol surface — audit carefully. | -| 8 | SoftwareVideo | Public init takes `mediaBox: MediaBox` + `resource: MediaResource`. | -| 12 | LocalMediaResources | Contains `VideoLibraryMediaResource: TelegramMediaResource` — blocked for conformance. | -| 14 | LegacyDataImport | Legacy path; audit scope. | -| 25 | PassportUI | Large surface; break into multiple tasks. | -| 36 | TelegramUI | Umbrella module; never as one wave. | - -**Blocked-by-conformance modules, explicitly out of all waves:** - -- `submodules/ICloudResources/Sources/ICloudResources.swift` — `ICloudFileResource` -- `submodules/InstantPageUI/Sources/InstantPageExternalMediaResource.swift` — `InstantPageExternalMediaResource` -- `submodules/LocalMediaResources/Sources/LocalMediaResources.swift` — `VideoLibraryMediaResource` -- `submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift` — `YoutubeEmbedStoryboardMediaResource` - -These classes must conform to `TelegramMediaResource` to satisfy the PostboxCoding serialization contract. They remain `import Postbox`. - ---- - -## What's explicitly NOT in this plan - -- Adding opt-in `EngineMediaResource` overloads alongside raw-`MediaResource` overloads. The facade is changed in place. -- Touching any class conforming to `TelegramMediaResource`. -- Editing `TelegramUI`, `PassportUI`, `LegacyDataImport`, or the other heavy-ref modules in the Future-waves table beyond what the Phase-1 call-site migrations require. -- Importing UIKit/Display into TelegramCore under any circumstance. -- Modifying `_internal_*` functions in TelegramCore — they stay on raw `MediaResource`. -- Any behavior change, performance tweak, or "while we're here" cleanup. diff --git a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md b/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md deleted file mode 100644 index a1d8b9fbcf..0000000000 --- a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md +++ /dev/null @@ -1,968 +0,0 @@ -# Postbox → TelegramEngine Wave 3 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add three thin forwarding methods on `TelegramEngine.Resources` for fetch/status/data, then migrate `SaveToCameraRoll` to use them, drop `import Postbox` from that module, and update all 23 call sites. - -**Architecture:** Two atomic commits on branch `refactor/postbox-to-engine-wave-3`. C1 adds the facades in isolation. C2 changes `SaveToCameraRoll`'s public API (drops the `postbox:` parameter, switches `FetchMediaDataState.data` payload from `MediaResourceData` to `EngineMediaResource.ResourceData`), rewrites the module's internals via `context.engine.resources.*`, removes `import Postbox`, and updates every caller in the same commit so the tree remains buildable. - -**Tech Stack:** Swift / Bazel. No unit tests exist in this repo — verification is a full project build. - -**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md) - -**Build command (use for every "full build" step):** - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 -``` - -The prefix `source ~/.zshrc 2>/dev/null;` is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. - ---- - -## Task 1: Add `TelegramEngine.Resources.fetch/status/data` facades (C1) - -**Files:** -- Modify: [submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:415-417](submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift#L415) - -- [ ] **Step 1: Insert the three facade methods** - -Open `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. Find the existing `applicationIcons()` method (currently the last method in the `Resources` class). Insert the three new methods immediately after it, still inside the `final class Resources` brace (before the closing `}`): - -```swift - public func applicationIcons() -> Signal { - return _internal_applicationIcons(account: account) - } - - public func fetch( - reference: MediaResourceReference, - userLocation: MediaResourceUserLocation, - userContentType: MediaResourceUserContentType - ) -> Signal { - return fetchedMediaResource( - mediaBox: self.account.postbox.mediaBox, - userLocation: userLocation, - userContentType: userContentType, - reference: reference - ) - } - - public func status( - resource: EngineMediaResource - ) -> Signal { - return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) - |> map { EngineMediaResource.FetchStatus($0) } - } - - public func data( - resource: EngineMediaResource, - pathExtension: String?, - waitUntilFetchStatus: Bool - ) -> Signal { - return self.account.postbox.mediaBox.resourceData( - resource._asResource(), - pathExtension: pathExtension, - option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) - ) - |> map { EngineMediaResource.ResourceData($0) } - } - } -} -``` - -- [ ] **Step 2: Full build — verify C1 compiles cleanly** - -Run the build command from the header. Expected: build succeeds with no errors. If a `signature mismatch` or `cannot find 'fetchedMediaResource'` error appears, double-check that `FetchedMediaResource.swift` and `MediaBox.swift` already export the referenced symbols (they do as of this plan's writing — no import changes are needed in `TelegramEngineResources.swift`, which already imports `Postbox`, `SwiftSignalKit`, and `TelegramApi`). - -- [ ] **Step 3: Commit C1** - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift -git commit -m "$(cat <<'EOF' -TelegramEngine.Resources: add fetch/status/data facades - -Thin forwarders over MediaBox for the narrow surface SaveToCameraRoll -needs. Takes EngineMediaResource and returns EngineMediaResource-typed -results where applicable. Wave-3 groundwork. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Pre-flight — re-inventory call sites and verify ShareController postbox - -No code changes in this task. Its purpose is to catch drift from the spec's inventory before editing code, per CLAUDE.md's "inventory at execution time" guidance. - -**Files:** (read-only) -- Spec inventory: [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md) -- Definition to verify: `submodules/ShareController/Sources/ShareController.swift` around line 2403 and `ShareControllerAppAccountContext` - -- [ ] **Step 1: Re-grep the current call-site set** - -Run: - -```bash -grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\(" submodules --include="*.swift" \ - | grep -v "SaveToCameraRoll/Sources/SaveToCameraRoll.swift" \ - | grep -v "private func saveToCameraRoll" \ - | grep -v "self\?\.saveToCameraRoll\|strongSelf\.saveToCameraRoll" -``` - -Expected output has exactly 23 lines across 14 files, matching the spec's inventory table: - -| Module | File | Expected count | -|---|---|---| -| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 | -| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 | -| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 | -| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 | -| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 | -| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 | -| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 | -| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 | -| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 | -| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 | -| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 | -| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 | -| ShareController | `Sources/ShareController.swift` | 3 | - -If the count or file list has drifted meaningfully from this table, **stop**, report the drift, and request a spec revision before continuing. Additions of one or two call sites can be folded in; larger drift should pause the wave. - -- [ ] **Step 2: Verify `ShareController:2406` postbox equivalence** - -Read `submodules/ShareController/Sources/ShareController.swift` lines 2395–2420. The private helper `saveToCameraRoll(messages:completion:)` contains `let postbox = self.currentContext.stateManager.postbox` and passes it to `SaveToCameraRoll.saveToCameraRoll`. After the migration, `SaveToCameraRoll` will use `context.account.postbox.mediaBox` internally. - -The enclosing function gates on `self.currentContext as? ShareControllerAppAccountContext`. In that code path, `accountContext.context.account` is the `Account` that `ShareControllerAppAccountContext` was built from, and `self.currentContext.stateManager` is that same account's state manager. Therefore `accountContext.context.account.postbox === self.currentContext.stateManager.postbox`. - -Confirm this by reading the definition of `ShareControllerAppAccountContext` in `submodules/AccountContext/Sources/ShareController.swift` (or the file where it's defined — grep for `ShareControllerAppAccountContext` to locate). If the `stateManager` there is derived from the same `account` whose `postbox` is reachable via `context.account.postbox`, treat the two as equivalent and proceed. If they can diverge (e.g., share-extension account switching creates a separate state manager), **stop** and abandon the ShareController:2406 edit with a recorded reason before continuing — the rest of the wave still applies. - -- [ ] **Step 3: Record verification outcome** - -Write a one-line note in the executor's task log noting either "ShareController:2406 postbox equivalence confirmed" or "ShareController:2406 abandoned — reason: ...". No commit. - ---- - -## Task 3: Migrate `SaveToCameraRoll` module - -This task changes the module's public API and internals. Build will fail after this task because all callers are still passing `postbox:` — that's expected and will be fixed in Task 4, which must land in the same commit as this task. - -**Files:** -- Modify: [submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift](submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift) (entire file rewritten as shown below) - -- [ ] **Step 1: Rewrite `SaveToCameraRoll.swift`** - -Replace the file's contents with: - -```swift -import Foundation -import UIKit -import SwiftSignalKit -import TelegramCore -import Photos -import Display -import MobileCoreServices -import DeviceAccess -import AccountContext -import LegacyComponents - -public enum FetchMediaDataState { - case progress(Float) - case data(EngineMediaResource.ResourceData) -} - -public func fetchMediaData(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, forceVideo: Bool = false) -> Signal<(FetchMediaDataState, Bool), NoError> { - var resource: TelegramMediaResource? - var isImage = true - var fileExtension: String? - var userContentType: MediaResourceUserContentType = .other - if let image = mediaReference.media as? TelegramMediaImage { - userContentType = .image - if let video = image.videoRepresentations.last, forceVideo { - resource = video.resource - isImage = false - } else if let representation = largestImageRepresentation(image.representations) { - resource = representation.resource - } - } else if let file = mediaReference.media as? TelegramMediaFile { - userContentType = MediaResourceUserContentType(file: file) - resource = file.resource - if file.isVideo || file.mimeType.hasPrefix("video/") { - isImage = false - } - let maybeExtension = ((file.fileName ?? "") as NSString).pathExtension - if !maybeExtension.isEmpty { - fileExtension = maybeExtension - } - } else if let webpage = mediaReference.media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { - if let file = content.file { - resource = file.resource - if file.isVideo { - isImage = false - } - } else if let image = content.image { - if let representation = largestImageRepresentation(image.representations) { - resource = representation.resource - } - } - } - if let customUserContentType { - userContentType = customUserContentType - } - - if let resource = resource { - let engineResource = EngineMediaResource(resource) - let fetchedData: Signal = Signal { subscriber in - let fetched = context.engine.resources.fetch( - reference: mediaReference.resourceReference(resource), - userLocation: userLocation, - userContentType: userContentType - ).start() - let status = context.engine.resources.status(resource: engineResource).start(next: { status in - switch status { - case .Local: - subscriber.putNext(.progress(1.0)) - case .Remote: - subscriber.putNext(.progress(0.0)) - case let .Fetching(_, progress): - subscriber.putNext(.progress(progress)) - case let .Paused(progress): - subscriber.putNext(.progress(progress)) - } - }) - let data = context.engine.resources.data( - resource: engineResource, - pathExtension: fileExtension, - waitUntilFetchStatus: true - ).start(next: { next in - subscriber.putNext(.data(next)) - }, completed: { - subscriber.putCompletion() - }) - return ActionDisposable { - fetched.dispose() - status.dispose() - data.dispose() - } - } - return fetchedData - |> map { data in - return (data, isImage) - } - } else { - return .complete() - } -} - -public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal { - let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference) - let videoData: Signal - if let video { - videoData = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video) - |> map { state, _ in - return state - } - |> map(Optional.init) - } else { - videoData = .single(nil) - } - - return combineLatest( - queue: Queue.mainQueue(), - mediaData, - videoData - ) - |> mapToSignal { stateAndIsImage, videoStateAndIsImage -> Signal in - let isImage = stateAndIsImage.1 - var mainData: EngineMediaResource.ResourceData? - var videoData: EngineMediaResource.ResourceData? - var waitForVideo = false - if let videoState = videoStateAndIsImage { - switch videoState { - case let .progress(value): - return .single(value * 0.95) - case let .data(data): - videoData = data - } - switch stateAndIsImage.0 { - case let .progress(value): - return .single(0.95 + 0.05 * value) - case let .data(data): - mainData = data - } - waitForVideo = true - } else { - switch stateAndIsImage.0 { - case let .progress(value): - return .single(value) - case let .data(data): - mainData = data - } - } - if let mainData, mainData.isComplete, videoData != nil || !waitForVideo { - return Signal { subscriber in - DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in - context.sharedContext.presentGlobalController(c, a) - }, openSettings: context.sharedContext.applicationBindings.openSettings, { authorized in - if !authorized { - subscriber.putCompletion() - return - } - - let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4" - if isImage, let videoData, let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) { - let id = UUID().uuidString - - let jpegWithID = addAssetIdentifierToJPEG(imageData, assetIdentifier: id)! - let outputVideoURL = URL(fileURLWithPath: NSTemporaryDirectory() + "\(id).mov") - - try? FileManager.default.copyItem(atPath: videoData.path, toPath: tempVideoPath) - - addAssetIdentifierToVideo(inputURL: URL(fileURLWithPath: tempVideoPath), outputURL: outputVideoURL, assetIdentifier: id) { success in - guard success else { return } - - PHPhotoLibrary.shared().performChanges({ - let request = PHAssetCreationRequest.forAsset() - - request.addResource(with: .photo, data: jpegWithID, options: nil) - request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil) - }, completionHandler: { _, error in - let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) - subscriber.putNext(1.0) - subscriber.putCompletion() - }) - } - } else { - PHPhotoLibrary.shared().performChanges({ - if isImage { - if let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) { - PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: nil) - } - } else { - if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: tempVideoPath) { - PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath)) - } - } - }, completionHandler: { _, error in - if let error { - print("\(error)") - } - let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) - subscriber.putNext(1.0) - subscriber.putCompletion() - }) - } - }) - - return ActionDisposable { - } - } - } else { - return .complete() - } - } -} - -public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal { - return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference) - |> mapToSignal { state, isImage -> Signal in - if case let .data(data) = state, data.isComplete { - return Signal { subscriber in - let pasteboard = UIPasteboard.general - - if mediaReference.media is TelegramMediaImage { - if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: .mappedIfSafe) { - pasteboard.setData(fileData, forPasteboardType: kUTTypeJPEG as String) - } - } - subscriber.putNext(Void()) - subscriber.putCompletion() - - return EmptyDisposable - } - } else { - return .complete() - } - } - |> mapToSignal { _ -> Signal in return .complete() } -} - -private func addAssetIdentifierToJPEG(_ imageData: Data, assetIdentifier: String) -> Data? { - guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let uti = CGImageSourceGetType(source), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - return nil - } - - let mutableData = NSMutableData() - guard let destination = CGImageDestinationCreateWithData(mutableData, uti, 1, nil) else { - return nil - } - - var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:] - - var maker = metadata[kCGImagePropertyMakerAppleDictionary as String] as? [String: Any] ?? [:] - maker["17"] = assetIdentifier - metadata[kCGImagePropertyMakerAppleDictionary as String] = maker - - CGImageDestinationAddImage(destination, cgImage, metadata as CFDictionary) - CGImageDestinationFinalize(destination) - - return mutableData as Data -} - -private func addAssetIdentifierToVideo(inputURL: URL, outputURL: URL, assetIdentifier: String, completion: @escaping (Bool) -> Void) { - let asset = AVAsset(url: inputURL) - - guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else { - completion(false) - return - } - - let identifierItem = AVMutableMetadataItem() - identifierItem.keySpace = .quickTimeMetadata - identifierItem.key = AVMetadataKey.quickTimeMetadataKeyContentIdentifier as NSString - identifierItem.value = assetIdentifier as NSString - - let stillImageTimeItem = AVMutableMetadataItem() - let keyStillImageTime = "com.apple.quicktime.still-image-time" - let keySpaceQuickTimeMetadata = "mdta" - stillImageTimeItem.key = keyStillImageTime as (NSCopying & NSObjectProtocol)? - stillImageTimeItem.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata) - stillImageTimeItem.value = 0 as (NSCopying & NSObjectProtocol)? - stillImageTimeItem.dataType = "com.apple.metadata.datatype.int8" - - exportSession.outputURL = outputURL - exportSession.outputFileType = .mov - exportSession.metadata = [identifierItem, stillImageTimeItem] - exportSession.shouldOptimizeForNetworkUse = true - - exportSession.exportAsynchronously { - completion(exportSession.status == .completed) - } -} -``` - -The key differences from the original file: - -1. `import Postbox` — removed. -2. `FetchMediaDataState.data(MediaResourceData)` → `FetchMediaDataState.data(EngineMediaResource.ResourceData)`. -3. Three public functions drop their `postbox: Postbox` parameter. -4. `var resource: MediaResource?` → `var resource: TelegramMediaResource?`. -5. Inside `fetchMediaData`: build an `EngineMediaResource(resource)` once, and call `context.engine.resources.fetch / status / data` instead of `fetchedMediaResource(...)` / `postbox.mediaBox.resourceStatus(...)` / `postbox.mediaBox.resourceData(...)`. -6. `var mainData: MediaResourceData?` / `var videoData: MediaResourceData?` → `var ...: EngineMediaResource.ResourceData?`. -7. `mainData.complete` → `mainData.isComplete`. `data.complete` (in `copyToPasteboard`) → `data.isComplete`. Field `data.path` is unchanged. - -- [ ] **Step 2: Do not build yet — proceed to Task 4** - -Builds will fail until every caller in Task 4 is migrated. Do not run the build command here. No commit yet either — Task 3 and Task 4 share a single atomic commit in Task 5. - ---- - -## Task 4: Update all 23 call sites - -Every call site does one or both of two edits: - -- **Edit A (all 23 sites):** drop `postbox: someExpression,` from the argument list. -- **Edit B (the 7 sites that destructure `fetchMediaData`):** rename `.complete` → `.isComplete` on the destructured data value; `.path` stays the same. - -Each sub-step below is one file. No builds between files. No commit. Task 5 builds everything together. - -**Sub-task 4.1 — InstantPageUI** - -- [ ] **File:** [submodules/InstantPageUI/Sources/InstantPageControllerNode.swift](submodules/InstantPageUI/Sources/InstantPageControllerNode.swift) - -At line 1027, replace: - -```swift - let _ = copyToPasteboard(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -with: - -```swift - let _ = copyToPasteboard(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -At line 1032, replace: - -```swift - let _ = saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -with: - -```swift - let _ = saveToCameraRoll(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -**Sub-task 4.2 — LegacyMediaPickerUI / LegacyAttachmentMenu.swift** (destructures) - -- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift) - -At line 173, replace: - -```swift - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) -``` - -with: - -```swift - let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) -``` - -In the `.start` block that follows (around line 175), replace `data.complete` with `data.isComplete` (only the `.complete` boolean access — do not touch `data.path`). - -At line 490, replace: - -```swift - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: editCurrentMedia) -``` - -with: - -```swift - let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: editCurrentMedia) -``` - -In the destructuring block that follows (around line 492), replace `data.complete` with `data.isComplete`. - -**Sub-task 4.3 — LegacyMediaPickerUI / LegacyAvatarPicker.swift** (destructures) - -- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift) - -At line 58, replace: - -```swift - let imageSignal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: false) -``` - -with: - -```swift - let imageSignal = fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: false) -``` - -In the `|> map` block immediately after (line ~60), replace `data.complete` with `data.isComplete`. - -At line 67, replace: - -```swift - let videoSignal = isVideo ? fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: true) -``` - -with: - -```swift - let videoSignal = isVideo ? fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: true) -``` - -In the `|> map` block immediately after (line ~69), replace `data.complete` with `data.isComplete`. - -**Sub-task 4.4 — BrowserUI / BrowserInstantPageContent.swift** - -- [ ] **File:** [submodules/BrowserUI/Sources/BrowserInstantPageContent.swift](submodules/BrowserUI/Sources/BrowserInstantPageContent.swift) - -At line 1175, replace: - -```swift - let _ = copyToPasteboard(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -with: - -```swift - let _ = copyToPasteboard(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -At line 1180, replace: - -```swift - let _ = saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -with: - -```swift - let _ = saveToCameraRoll(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() -``` - -**Sub-task 4.5 — GalleryUI / ChatImageGalleryItem.swift** (one destructures) - -- [ ] **File:** [submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift](submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift) - -At line 732, replace: - -```swift - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) -``` - -with: - -```swift - let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) -``` - -In the `.start` block that follows (around line 734), replace `data.complete` with `data.isComplete`. - -At line 758, replace: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: media) -``` - -with: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: media) -``` - -**Sub-task 4.6 — GalleryUI / UniversalVideoGalleryItem.swift** - -- [ ] **File:** [submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift](submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift) - -At line 3764, replace: - -```swift - let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) -``` - -with: - -```swift - let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) -``` - -At line 3810, replace: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) -``` - -with: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) -``` - -At line 3867, replace: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference) -``` - -with: - -```swift - let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference) -``` - -**Sub-task 4.7 — TelegramUI / MediaEditorScreen / MediaEditorScreen.swift** (destructures) - -- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift) - -At line 5136, in the multi-line call starting with `let _ = (fetchMediaData(`, delete the line ` postbox: self.context.account.postbox,`. The remaining call should read: - -```swift - let _ = (fetchMediaData( - context: self.context, - userLocation: .other, - mediaReference: file - ) |> deliverOnMainQueue).start(next: { [weak self] state, _ in -``` - -Inside this closure, the destructuring is `if case let .data(data) = state { let path = data.path ... }` — `data.path` stays unchanged, and this site does not access `data.complete` (verified against the current file). No Edit B rename needed here. - -**Sub-task 4.8 — TelegramUI / MediaEditorScreen / EditStories.swift** (destructures) - -- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift) - -At line 37, replace: - -```swift - return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) -``` - -with: - -```swift - return fetchMediaData(context: context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) -``` - -At line 39 (inside the `mapToSignal`), replace: - -```swift - guard case let .data(data) = value, data.complete else { -``` - -with: - -```swift - guard case let .data(data) = value, data.isComplete else { -``` - -(`data.path` accesses below this line remain unchanged.) - -**Sub-task 4.9 — TelegramUI / ChatQrCodeScreen / ChatQrCodeScreen.swift** (destructures) - -- [ ] **File:** [submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift](submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift) - -At line 2505, replace: - -```swift - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) -``` - -with: - -```swift - let _ = (fetchMediaData(context: context, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) -``` - -At line 2507, replace: - -```swift - guard case let .data(data) = value, data.complete else { -``` - -with: - -```swift - guard case let .data(data) = value, data.isComplete else { -``` - -**Sub-task 4.10 — TelegramUI / StoryContainerScreen / StoryItemSetContainerComponent.swift** - -- [ ] **File:** [submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift](submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift) - -At line 5980, replace: - -```swift - let disposable = (saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) -``` - -with: - -```swift - let disposable = (saveToCameraRoll(context: component.context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) -``` - -**Sub-task 4.11 — TelegramUI / PeerInfoStoryGridScreen / PeerInfoStoryGridScreen.swift** - -- [ ] **File:** [submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift](submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift) - -At line 268, replace: - -```swift - signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) -``` - -with: - -```swift - signals.append(saveToCameraRoll(context: component.context, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) -``` - -**Sub-task 4.12 — TelegramUI / Sources / ChatInterfaceStateContextMenus.swift** - -- [ ] **File:** [submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift](submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift) - -At line 1419, replace: - -```swift - let _ = (saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) -``` - -with: - -```swift - let _ = (saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) -``` - -**Sub-task 4.13 — TelegramUI / Sources / SaveMediaToFiles.swift** (destructures) - -- [ ] **File:** [submodules/TelegramUI/Sources/SaveMediaToFiles.swift](submodules/TelegramUI/Sources/SaveMediaToFiles.swift) - -At line 27, replace: - -```swift - var signal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: fileReference.abstract) -``` - -with: - -```swift - var signal = fetchMediaData(context: context, userLocation: .other, mediaReference: fileReference.abstract) -``` - -At line 63, replace: - -```swift - if data.complete { -``` - -with: - -```swift - if data.isComplete { -``` - -(`data.path` accesses in the block below remain unchanged.) - -**Sub-task 4.14 — ShareController / ShareController.swift** - -- [ ] **File:** [submodules/ShareController/Sources/ShareController.swift](submodules/ShareController/Sources/ShareController.swift) - -At line 2406, after verifying Task 2's postbox-equivalence, replace: - -```swift - return SaveToCameraRoll.saveToCameraRoll(context: context, postbox: postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) -``` - -with: - -```swift - return SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) -``` - -Also delete the now-unused local binding above (line 2403): - -```swift - let postbox = self.currentContext.stateManager.postbox -``` - -(This line is used only by the `saveToCameraRoll` call on line 2406. If the build later flags it as unused instead of an error, leave it; but preferred is to remove the dead binding.) - -At line 2432, replace: - -```swift - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) -``` - -with: - -```swift - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) -``` - -At line 2441, replace: - -```swift - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) -``` - -with: - -```swift - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) -``` - -(The abandonment branch: if Task 2's verification found `stateManager.postbox` and `account.postbox` are non-equivalent, skip the `line 2406` edit, leave `let postbox = self.currentContext.stateManager.postbox` in place, and revert Task 3's change to the `saveToCameraRoll` public signature only for this one callsite — which is impossible without duplicate signatures, so in that case abandon the entire wave and record the reason in a new commit to the plan.) - ---- - -## Task 5: Full build and commit C2 - -- [ ] **Step 1: Run the full project build** - -Run the build command from the header. Expected: build succeeds with no errors across all modules. - -If there are failures, they fall into a few predictable categories and are fixed in place — do not split into another commit: - -- **"cannot convert value of type 'Postbox' to expected argument type"** — a call site was missed. Grep again for `postbox: ` usages in the migrated files and fix. -- **"value of type 'EngineMediaResource.ResourceData' has no member 'complete'"** — an Edit B site was missed. Rename to `isComplete`. -- **"use of unresolved identifier 'fetchedMediaResource'" or similar inside `SaveToCameraRoll.swift`** — indicates `import Postbox` was dropped but a bare Postbox top-level function is still referenced. Replace the call with the engine facade introduced in Task 1. -- **Warnings about unused local `let postbox = ...`** — delete the binding. - -Re-run the build after each fix until it succeeds. - -- [ ] **Step 2: Stage all touched files** - -```bash -git add \ - submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift \ - submodules/InstantPageUI/Sources/InstantPageControllerNode.swift \ - submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift \ - submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift \ - submodules/BrowserUI/Sources/BrowserInstantPageContent.swift \ - submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift \ - submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift \ - submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift \ - submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift \ - submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift \ - submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift \ - submodules/TelegramUI/Sources/SaveMediaToFiles.swift \ - submodules/ShareController/Sources/ShareController.swift -``` - -- [ ] **Step 3: Verify the diff is clean** - -Run: - -```bash -git diff --staged --stat -``` - -Expected: exactly 15 files changed, with SaveToCameraRoll.swift having the largest diff (the full-file rewrite) and each call-site file showing small line-count changes. - -- [ ] **Step 4: Commit C2** - -```bash -git commit -m "$(cat <<'EOF' -SaveToCameraRoll: drop import Postbox via engine.resources facades - -Migrates SaveToCameraRoll's three public functions to take context -only (no more postbox:), switches the FetchMediaDataState.data payload -from MediaResourceData to EngineMediaResource.ResourceData, rewrites -internals via TelegramEngine.Resources.fetch/status/data, and drops -import Postbox from the module. All 23 call sites across 14 files -updated in the same commit to keep the tree buildable. - -Wave-3 of the Postbox -> TelegramEngine refactor. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Verify branch log** - -Run: - -```bash -git log --oneline refactor/postbox-to-engine-wave-3 | head -5 -``` - -Expected: the top two commits on the branch are `SaveToCameraRoll: drop import Postbox ...` (C2) and `TelegramEngine.Resources: add fetch/status/data facades` (C1), above the previous spec commits. - -- [ ] **Step 6: Update CLAUDE.md tally** - -Open `CLAUDE.md`, find the "Modules currently free of `import Postbox`" section, and add `SaveToCameraRoll (wave 3)` to the bullet list. Also add a "Wave 3 outcome (2026-04-18)" subsection documenting: three facades added on `TelegramEngine.Resources`, `SaveToCameraRoll` fully de-Postboxed, 23 call sites migrated. If any call site was abandoned in Task 2, record the reason here. - -Commit: - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -CLAUDE.md: record wave-3 outcome - -Adds SaveToCameraRoll to the Postbox-free module tally and documents -the three new TelegramEngine.Resources facades added in wave 3. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Success criteria - -- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift` contains no `import Postbox`. -- `grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\\(" submodules --include="*.swift" | grep "postbox:"` returns zero matches outside of the private `collectExternalShareResource`/`collectExternalShareItems` helpers in `ShareController.swift` (which take their own `postbox:` parameters unrelated to SaveToCameraRoll). -- Full build succeeds in `debug_sim_arm64` configuration. -- Three branch commits above the spec commits: C1 (facades), C2 (SaveToCameraRoll + callers), C3 (CLAUDE.md tally). diff --git a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md b/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md deleted file mode 100644 index 21e88108d6..0000000000 --- a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md +++ /dev/null @@ -1,500 +0,0 @@ -# Postbox → TelegramEngine Wave 4 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate `TelegramEngine.Stickers.uploadSticker`'s public surface — `peer: Peer → EnginePeer`, `resource: MediaResource → EngineMediaResource`, `thumbnail: MediaResource? → EngineMediaResource?`, and `UploadStickerStatus.complete(CloudDocumentMediaResource, String) → .complete(EngineMediaResource, String)` — with one atomic commit touching the facade, the internal enum, and the two call sites. - -**Architecture:** Two commits on branch `refactor/postbox-to-engine-wave-4`. C1 is the atomic four-file code change. C2 is the CLAUDE.md tally update. `_internal_uploadSticker` keeps its raw `Peer`/`MediaResource` signature; the facade does all the wrapping/unwrapping. One spec-allowed one-line exception: `_internal_uploadSticker` constructs `EngineMediaResource(uploadedResource)` at the `.complete(...)` result-construction site to keep `UploadStickerStatus` as a single enum instead of splitting into raw+engine variants. - -**Tech Stack:** Swift / Bazel. No unit tests in this repo — verification is a full project build. - -**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-4-design.md) - -**Build command** (use for every "full build" step): - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 -``` - -The `source ~/.zshrc` prefix is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. For a background build from the controller session, prefer `run_in_background: true` and monitor by tailing the task output file (subagent-spawned background builds orphan when the subagent shell terminates). - ---- - -## Task 1: Pre-flight re-verification - -No code changes. Purpose: re-confirm the facade call-site count and the MediaEditorScreen line numbers haven't drifted. - -**Files:** (read-only) - -- [ ] **Step 1: Re-grep facade call sites** - -```bash -grep -rnE "\.uploadSticker\(" submodules --include="*.swift" \ - | grep -v "/TelegramEngine/Stickers/" \ - | grep -v "self\.uploadSticker\|strongSelf\.uploadSticker\|self\?\.uploadSticker" -``` - -Expected output: exactly 2 lines - -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91` -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099` - -If the count or line numbers have drifted meaningfully, stop and revise the plan before editing. - -- [ ] **Step 2: Re-read MediaEditorScreen block** - -```bash -sed -n '8080,8190p' submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift -``` - -Visually confirm: -- Line ~8097 has `.complete(resource, mimeType)` inside an `if let resource = resource as? CloudDocumentMediaResource { … }` branch. -- Line ~8099 has `context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, …)`. -- Line ~8105 has `case let .complete(resource, _):` destructuring the inner `.mapToSignal` status. -- Line ~8106 has `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, …)`. -- Line ~8119 has `ImportSticker(resource: .standalone(resource: resource), …)` inside `case let .createStickerPack(title):`. -- Line ~8138 has a second `ImportSticker(resource: .standalone(resource: resource), …)` inside `case let .addToStickerPack(pack, title):`. -- Line ~8178 has a second `case let .complete(resource, _):` in the outer `.startStandalone(next: …)` handler. -- Line ~8180 has `stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, …)`. - -- [ ] **Step 3: Confirm `stickerFile` signature** - -```bash -grep -nE "^private func stickerFile\(" submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift -``` - -Expected: `private func stickerFile(resource: TelegramMediaResource, thumbnailResource: TelegramMediaResource?, size: Int64, dimensions: PixelDimensions, duration: Double?, isVideo: Bool) -> TelegramMediaFile` at line ~9196. This confirms `stickerFile` takes `TelegramMediaResource` (requires `resource._asResource()` at every call). - -- [ ] **Step 4: Confirm ImportStickerPackController's `peer` type** - -```bash -sed -n '82,95p' submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift -``` - -Expected pattern: -```swift -let _ = (self.context.account.postbox.loadedPeerWithId(self.context.account.peerId) -|> deliverOnMainQueue).start(next: { [weak self] peer in -``` - -`postbox.loadedPeerWithId` returns `Signal`. The local `peer` is therefore a raw `Peer`, not an `EnginePeer`. The call-site edit will need `EnginePeer(peer)` to wrap. - -If any of these expectations fails to match the current source, stop and revise the plan. - ---- - -## Task 2: Migrate `UploadStickerStatus` enum and internal wrap - -No build; the project won't compile until Tasks 3–5 also land. Do not commit. - -**File:** `submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift` - -- [ ] **Step 1: Update enum payload (line 7–10)** - -Replace: - -```swift -public enum UploadStickerStatus { - case progress(Float) - case complete(CloudDocumentMediaResource, String) -} -``` - -with: - -```swift -public enum UploadStickerStatus { - case progress(Float) - case complete(EngineMediaResource, String) -} -``` - -- [ ] **Step 2: Update the `.complete(...)` construction in `_internal_uploadSticker` (line ~97)** - -Replace the line reading: - -```swift - return .single(.complete(uploadedResource, file.mimeType)) -``` - -with: - -```swift - return .single(.complete(EngineMediaResource(uploadedResource), file.mimeType)) -``` - -Nothing else in `_internal_uploadSticker` changes. In particular its parameter list (`peer: Peer, resource: MediaResource, thumbnail: MediaResource? = nil, …`) stays exactly as is. - ---- - -## Task 3: Migrate the public facade signature - -No build; no commit. - -**File:** `submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift` - -- [ ] **Step 1: Update the `uploadSticker` facade (line 85–87)** - -Replace: - -```swift - public func uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer, resource: resource, thumbnail: thumbnail, alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) - } -``` - -with: - -```swift - public func uploadSticker(peer: EnginePeer, resource: EngineMediaResource, thumbnail: EngineMediaResource?, alt: String, dimensions: PixelDimensions, duration: Double?, mimeType: String) -> Signal { - return _internal_uploadSticker(account: self.account, peer: peer._asPeer(), resource: resource._asResource(), thumbnail: thumbnail?._asResource(), alt: alt, dimensions: dimensions, duration: duration, mimeType: mimeType) - } -``` - -No other method in `TelegramEngineStickers.swift` changes. - ---- - -## Task 4: Migrate `ImportStickerPackController.swift:91` - -No build; no commit. - -**File:** `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift` - -- [ ] **Step 1: Update the facade call (line ~91)** - -Replace: - -```swift - signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: peer, resource: resource._asResource(), thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) -``` - -with: - -```swift - signals.append(strongSelf.context.engine.stickers.uploadSticker(peer: EnginePeer(peer), resource: resource, thumbnail: nil, alt: sticker.emojis.first ?? "", dimensions: PixelDimensions(width: 512, height: 512), duration: nil, mimeType: sticker.mimeType) -``` - -Two changes: `peer` (raw `Peer`) → `EnginePeer(peer)`, and `resource._asResource()` → `resource` (the local `resource` is an `EngineMediaResource`). - -- [ ] **Step 2: Update the destructure re-wrap (line ~99)** - -Replace: - -```swift - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, EngineMediaResource(resource)) - } else { -``` - -with: - -```swift - case let .complete(resource, mimeType): - if ["application/x-tgsticker", "video/webm"].contains(mimeType) { - return (sticker.uuid, .verified, resource) - } else { -``` - -One change: `EngineMediaResource(resource)` → `resource`. The destructured `resource` is now already an `EngineMediaResource`. - -Nothing else in this file changes. - ---- - -## Task 5: Migrate `MediaEditorScreen.swift` sticker-upload block - -No build; no commit. This task touches multiple lines inside a single nested block (~8084–8190). The `UploadStickerStatus` payload migration cascades: wherever the code constructs or destructures `.complete(...)`, types change. - -**File:** `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` - -- [ ] **Step 1: Wrap at the direct construction site (line ~8097)** - -Replace the line reading: - -```swift - return .single((.progress(1.0), nil)) |> then(.single((.complete(resource, mimeType), nil))) -``` - -with: - -```swift - return .single((.progress(1.0), nil)) |> then(.single((.complete(EngineMediaResource(resource), mimeType), nil))) -``` - -Context: this is inside `if let resource = resource as? CloudDocumentMediaResource { … }`, so `resource` here is `CloudDocumentMediaResource`; the outer tuple's `UploadStickerStatus.complete` now takes `EngineMediaResource`. - -- [ ] **Step 2: Migrate the facade call (line ~8099)** - -Replace: - -```swift - return context.engine.stickers.uploadSticker(peer: peer._asPeer(), resource: resource, thumbnail: file.previewRepresentations.first?.resource, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) -``` - -with: - -```swift - return context.engine.stickers.uploadSticker(peer: peer, resource: EngineMediaResource(resource), thumbnail: file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }, alt: "", dimensions: dimensions, duration: duration, mimeType: mimeType) -``` - -Three changes: `peer._asPeer()` → `peer` (local is `EnginePeer`); `resource` → `EngineMediaResource(resource)` (local is raw `MediaResource` from the outer enum destructure); `file.previewRepresentations.first?.resource` → `file.previewRepresentations.first.flatMap { EngineMediaResource($0.resource) }`. - -- [ ] **Step 3: Unwrap at inner-handler `stickerFile` call (line ~8106)** - -Replace: - -```swift - case let .complete(resource, _): - let file = stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: file.size ?? 0, dimensions: dimensions, duration: file.duration, isVideo: isVideo) -``` - -with: - -```swift - case let .complete(resource, _): - let file = stickerFile(resource: resource._asResource(), thumbnailResource: file.previewRepresentations.first?.resource, size: file.size ?? 0, dimensions: dimensions, duration: file.duration, isVideo: isVideo) -``` - -The destructured `resource` is now an `EngineMediaResource`. `stickerFile` (see line 9196) takes `TelegramMediaResource`, so unwrap with `._asResource()`. `file.previewRepresentations.first?.resource` is already a `TelegramMediaResource?` — no change there. - -- [ ] **Step 4: Unwrap at `.createStickerPack` sticker construction (line ~8119)** - -Replace: - -```swift - case let .createStickerPack(title): - let sticker = ImportSticker( - resource: .standalone(resource: resource), - emojis: emojis, - dimensions: dimensions, - duration: duration, - mimeType: mimeType, - keywords: "" - ) -``` - -with: - -```swift - case let .createStickerPack(title): - let sticker = ImportSticker( - resource: .standalone(resource: resource._asResource()), - emojis: emojis, - dimensions: dimensions, - duration: duration, - mimeType: mimeType, - keywords: "" - ) -``` - -`MediaResourceReference.standalone(resource:)` takes `MediaResource`; `resource` here is the `EngineMediaResource` destructured at line ~8105. Unwrap with `._asResource()`. - -- [ ] **Step 5: Unwrap at `.addToStickerPack` sticker construction (line ~8138)** - -Replace: - -```swift - case let .addToStickerPack(pack, title): - let sticker = ImportSticker( - resource: .standalone(resource: resource), - emojis: emojis, - dimensions: dimensions, - duration: duration, - mimeType: mimeType, - keywords: "" - ) -``` - -with: - -```swift - case let .addToStickerPack(pack, title): - let sticker = ImportSticker( - resource: .standalone(resource: resource._asResource()), - emojis: emojis, - dimensions: dimensions, - duration: duration, - mimeType: mimeType, - keywords: "" - ) -``` - -Same unwrap as Step 4. - -- [ ] **Step 6: Unwrap at outer-handler `stickerFile` call (line ~8178–8180)** - -Replace: - -```swift - case let .complete(resource, _): - let navigationController = self.effectiveNavigationController as? NavigationController - - let result: MediaEditorScreenImpl.Result - switch action { - case .update: - result = MediaEditorScreenImpl.Result(media: .sticker(file: file, emoji: emojis)) - case .upload, .send: - let file = stickerFile(resource: resource, thumbnailResource: file.previewRepresentations.first?.resource, size: resource.size ?? 0, dimensions: dimensions, duration: self.preferredStickerDuration(), isVideo: isVideo) -``` - -with: - -```swift - case let .complete(resource, _): - let rawResource = resource._asResource() - let navigationController = self.effectiveNavigationController as? NavigationController - - let result: MediaEditorScreenImpl.Result - switch action { - case .update: - result = MediaEditorScreenImpl.Result(media: .sticker(file: file, emoji: emojis)) - case .upload, .send: - let file = stickerFile(resource: rawResource, thumbnailResource: file.previewRepresentations.first?.resource, size: rawResource.size ?? 0, dimensions: dimensions, duration: self.preferredStickerDuration(), isVideo: isVideo) -``` - -Two changes: introduce `let rawResource = resource._asResource()` at the top of the `case let .complete(resource, _):` block, and use `rawResource` at both the `resource:` argument and the `size: rawResource.size ?? 0` read. (`EngineMediaResource` does not expose `.size`; only the raw `MediaResource` does.) - -- [ ] **Step 7: Scan for any missed downstream use** - -Run inside the repo: - -```bash -sed -n '8080,8200p' submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift | grep -nE "\bresource\b" -``` - -Skim the output. Every reference to the destructured `resource` inside the nested block (lines ~8084–8190) should either be the new `EngineMediaResource`-typed local or a wrapped/unwrapped form. If you spot a use that would still expect `CloudDocumentMediaResource`-specific members or raw `MediaResource` without the unwrap, stop and report. - ---- - -## Task 6: Full build and commit C1 - -- [ ] **Step 1: Run the full project build** - -Run the build command from the header. Expected: clean success. - -Typical failure modes and fixes (do them inline — do not split into another commit): - -- **"cannot convert value of type 'Peer' to expected argument type 'EnginePeer'"** — a call site was missed or the wrap is misplaced. -- **"value of type 'EngineMediaResource' has no member 'size'"** — Task 5 Step 6 wasn't applied (or similar `.size`/`.id.stringRepresentation`/`.isEqual` access on `EngineMediaResource`). -- **"cannot convert value of type 'EngineMediaResource' to expected argument type 'TelegramMediaResource'"** — an `._asResource()` is missing at a `stickerFile(...)` or `.standalone(resource:)` call. -- **"reference to enum case 'UploadStickerStatus.complete' requires that 'CloudDocumentMediaResource' conform to 'something'"** — a `.complete(...)` construction site wasn't migrated to pass `EngineMediaResource`. - -Re-run the build after each fix. - -- [ ] **Step 2: Stage the 4 files** - -```bash -git add \ - submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift \ - submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift \ - submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift -``` - -- [ ] **Step 3: Verify diff scope** - -```bash -git diff --staged --stat -``` - -Expected: exactly 4 files staged, with MediaEditorScreen having the largest diff (~8 line changes), ImportStickers ~2, TelegramEngineStickers ~2, ImportStickerPackController ~2. - -- [ ] **Step 4: Commit** - -```bash -git commit -m "$(cat <<'EOF' -TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource - -Public facade and UploadStickerStatus.complete payload now use -EnginePeer and EngineMediaResource instead of raw Peer / MediaResource -/ CloudDocumentMediaResource. _internal_uploadSticker stays on raw -Postbox types with one inline EngineMediaResource(uploadedResource) -construction at the .complete result site. - -Both call sites (ImportStickerPackController, MediaEditorScreen) -updated atomically in the same commit. - -Wave-4 of the Postbox -> TelegramEngine refactor. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Verify branch state** - -```bash -git log --oneline master..HEAD -``` - -Expected (newest at top): - -- ` TelegramEngine.Stickers.uploadSticker: migrate to EnginePeer + EngineMediaResource` -- `b6392bce7c docs(spec): wave-4 enumerate MediaEditorScreen downstream edits` -- `59a01b0d4d docs(spec): wave-4 TelegramEngine.Stickers.uploadSticker facade migration` - ---- - -## Task 7: Update CLAUDE.md tally and commit C2 - -- [ ] **Step 1: Add Wave 4 outcome subsection** - -Open `CLAUDE.md`. Find the "Wave 3 outcome (2026-04-18)" section (currently around line 96 onward). Insert a new subsection **after** Wave 3's outcome block and **before** "### Modules currently free of `import Postbox` (running tally)": - -```markdown -### Wave 4 outcome (2026-04-18) - -1 `TelegramEngine` facade migrated in place to `EnginePeer` + `EngineMediaResource` (signatures changed; `_internal_uploadSticker` keeps raw `Peer`/`MediaResource`): - -- `TelegramEngine.Stickers.uploadSticker(peer: Peer → EnginePeer, resource: MediaResource → EngineMediaResource, thumbnail: MediaResource? → EngineMediaResource?, …)` - -1 public enum payload migrated: `UploadStickerStatus.complete(CloudDocumentMediaResource, String)` → `.complete(EngineMediaResource, String)`. The internal `_internal_uploadSticker` constructs `EngineMediaResource(uploadedResource)` at the result site — a narrow, spec-allowed one-line deviation from "internal Postbox-facing stays raw", taken to keep `UploadStickerStatus` as a single public enum. - -2 call sites migrated atomically with the facade: -- `submodules/ImportStickerPackUI/Sources/ImportStickerPackController.swift:91` -- `submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift:8099` (plus ~5 cascading sites in the same enclosing block for the new `UploadStickerStatus.complete` payload) - -No module becomes Postbox-free in this wave (both caller files import Postbox for unrelated reasons). - -Plan: `docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-4.md` -``` - -- [ ] **Step 2: Remove the `uploadSticker` entry from "Known future-wave candidates"** - -Still in `CLAUDE.md`, find the "Known future-wave candidates" list and delete this bullet (currently around line 143): - -```markdown -- `TelegramEngine.Stickers.uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, …)` — same MediaResource migration as wave 2, plus `peer: Peer` which would naturally migrate to `EnginePeer` at the same time. Self-contained to a small number of call sites. -``` - -Do not touch the other bullets in that list. - -- [ ] **Step 3: Commit** - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -CLAUDE.md: record wave-4 outcome - -Documents the uploadSticker facade migration + UploadStickerStatus -payload change; removes uploadSticker from the future-wave candidates -list. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Success criteria - -- `TelegramEngine.Stickers.uploadSticker`'s public signature references neither `Peer` nor `MediaResource` nor `CloudDocumentMediaResource`. -- `UploadStickerStatus.complete`'s payload is `(EngineMediaResource, String)`. -- `_internal_uploadSticker`'s signature is unchanged (still raw `Peer` / `MediaResource`). -- Full build succeeds in `debug_sim_arm64`. -- The two call sites (`ImportStickerPackController`, `MediaEditorScreen`) and the cascading sites within MediaEditorScreen's nested block compile against the new types. -- `CLAUDE.md` has a "Wave 4 outcome (2026-04-18)" subsection; the `uploadSticker` bullet is gone from "Known future-wave candidates". -- Branch `refactor/postbox-to-engine-wave-4` contains 4 commits above `master`: 2 docs (spec + spec fix), 1 code (C1), 1 tally (C2). diff --git a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md b/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md deleted file mode 100644 index 9b77640185..0000000000 --- a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md +++ /dev/null @@ -1,381 +0,0 @@ -# Postbox → TelegramEngine Wave 5 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate `uploadSecureIdFile`'s public surface to `(context:, engine: TelegramEngine, resource: EngineMediaResource)`, refactor `SecureIdVerificationDocumentsContext` to take `engine: TelegramEngine` in place of raw `Postbox` + `Network`, and drop `import Postbox` from that caller module. Land as one atomic code commit + one CLAUDE.md tally commit on branch `refactor/postbox-to-engine-wave-5`. - -**Architecture:** Three files land together in C1 because the facade signature change, the caller class's stored-property change, and the one instantiation site are mutually breaking. The facade body inside TelegramCore continues to access raw Postbox types via `engine.account.postbox` / `engine.account.network` — CLAUDE.md's "internal Postbox-facing stays raw" rule applies to the body, while the public signature is the clean surface. C2 updates the CLAUDE.md tally and removes the wave-5-named bullet from "Known future-wave candidates". - -**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build. - -**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-5-design.md) - -**Build command** (use for every "full build" step): - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 -``` - -The `source ~/.zshrc` prefix is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. For a long-running build, prefer `run_in_background: true` from the controller session (subagent-spawned background builds orphan when the subagent's shell terminates). - ---- - -## Task 1: Pre-flight re-verification - -No code changes. Confirms the inventory hasn't drifted. - -- [ ] **Step 1: Re-grep `uploadSecureIdFile` call sites** - -```bash -grep -rnE "uploadSecureIdFile\(" submodules --include="*.swift" | grep -v "/SecureId/" -``` - -Expected: exactly 1 match — `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift:43`. If the count or file has drifted, stop and revise the plan. - -- [ ] **Step 2: Re-grep `SecureIdVerificationDocumentsContext(...)` instantiation sites** - -```bash -grep -rnE "SecureIdVerificationDocumentsContext\(" submodules --include="*.swift" | grep -v "final class SecureIdVerificationDocumentsContext" -``` - -Expected: exactly 1 match — `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift:2172`. If drift, stop. - -- [ ] **Step 3: Confirm `AccountContext.engine` protocol requirement** - -```bash -grep -nE "var engine: TelegramEngine" submodules/AccountContext/Sources/AccountContext.swift -``` - -Expected: one line matching `var engine: TelegramEngine { get }` (the protocol requirement). This confirms `self.context.engine` will be available at the instantiation site in Task 4. - -- [ ] **Step 4: Confirm `info.resource` type** - -```bash -grep -nE "let resource:" submodules/PassportUI/Sources/SecureIdVerificationDocument.swift -``` - -Expected: two matches, both showing `resource: TelegramMediaResource`. Confirms `EngineMediaResource(info.resource)` will compile (the `EngineMediaResource(_:)` init takes `MediaResource`, which `TelegramMediaResource` inherits). - ---- - -## Task 2: Migrate `uploadSecureIdFile`'s public facade and body - -No build; no commit. Tasks 2–4 share one atomic commit in Task 5. - -**File:** `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift` - -- [ ] **Step 1: Replace the function signature and body** - -Find the `uploadSecureIdFile` function (currently starts at line 90). Replace the entire function (from `public func uploadSecureIdFile` through its closing `}`) with this version: - -```swift -public func uploadSecureIdFile(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource) -> Signal { - return engine.account.postbox.mediaBox.resourceData(resource._asResource()) - |> mapError { _ -> UploadSecureIdFileError in - } - |> mapToSignal { next -> Signal in - if !next.complete { - return .complete() - } - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: next.path)) else { - return .fail(.generic) - } - - guard let encryptedData = encryptedSecureIdFile(context: context, data: data) else { - return .fail(.generic) - } - - return multipartUpload(network: engine.account.network, postbox: engine.account.postbox, source: .data(encryptedData.data), encrypt: false, tag: TelegramMediaResourceFetchTag(statsCategory: .image, userContentType: .image), hintFileSize: nil, hintFileIsLarge: false, forceNoBigParts: false) - |> mapError { _ -> UploadSecureIdFileError in - return .generic - } - |> mapToSignal { result -> Signal in - switch result { - case let .progress(value): - return .single(.progress(value)) - case let .inputFile(.inputFile(fileData)): - return .single(.result(UploadedSecureIdFile(id: fileData.id, parts: fileData.parts, md5Checksum: fileData.md5Checksum, fileHash: encryptedData.hash, encryptedSecret: encryptedData.encryptedSecret), encryptedData.data)) - default: - return .fail(.generic) - } - } - } -} -``` - -Changes from the original: - -- Signature: `(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` → `(context: SecureIdAccessContext, engine: TelegramEngine, resource: EngineMediaResource)`. -- Line 1 of body: `postbox.mediaBox.resourceData(resource)` → `engine.account.postbox.mediaBox.resourceData(resource._asResource())`. -- Inside the `mapToSignal`: `multipartUpload(network: network, postbox: postbox, ...)` → `multipartUpload(network: engine.account.network, postbox: engine.account.postbox, ...)`. - -No other file in `TelegramCore/Sources/TelegramEngine/SecureId/` is touched. No imports change inside `UploadSecureIdFile.swift` — it continues to `import Foundation`, `import Postbox`, `import MtProtoKit`, `import SwiftSignalKit`, which remain correct (the body still uses raw Postbox types via `engine.account.postbox`). - ---- - -## Task 3: Migrate `SecureIdVerificationDocumentsContext` - -No build; no commit. - -**File:** `submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift` - -- [ ] **Step 1: Drop `import Postbox`** - -Replace the import block at the top (lines 1–4): - -```swift -import Foundation -import Postbox -import TelegramCore -import SwiftSignalKit -``` - -with: - -```swift -import Foundation -import TelegramCore -import SwiftSignalKit -``` - -Only `Postbox` is removed. The three remaining imports stay. - -- [ ] **Step 2: Replace stored properties** - -Find the `final class SecureIdVerificationDocumentsContext` block (starting around line 18). Replace lines 20–21: - -```swift - private let postbox: Postbox - private let network: Network -``` - -with: - -```swift - private let engine: TelegramEngine -``` - -- [ ] **Step 3: Update the constructor** - -Replace the `init` (lines 26–31): - -```swift - init(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.postbox = postbox - self.network = network - self.context = context - self.update = update - } -``` - -with: - -```swift - init(engine: TelegramEngine, context: SecureIdAccessContext, update: @escaping (Int64, SecureIdVerificationLocalDocumentState) -> Void) { - self.engine = engine - self.context = context - self.update = update - } -``` - -- [ ] **Step 4: Update the `uploadSecureIdFile` call inside `stateUpdated`** - -Find line 43, which currently reads: - -```swift - disposable.set((uploadSecureIdFile(context: self.context, postbox: self.postbox, network: self.network, resource: info.resource) -``` - -Replace with: - -```swift - disposable.set((uploadSecureIdFile(context: self.context, engine: self.engine, resource: EngineMediaResource(info.resource)) -``` - -Two changes: -- `postbox: self.postbox, network: self.network` → `engine: self.engine`. -- `resource: info.resource` → `resource: EngineMediaResource(info.resource)`. - -No other line in this file changes. The `DocumentContext` inner class is untouched. The `stateUpdated` method's outer structure is untouched. - ---- - -## Task 4: Update the instantiation site - -No build; no commit. - -**File:** `submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift` - -- [ ] **Step 1: Update line 2172** - -Find line 2172, which currently reads: - -```swift - self.uploadContext = SecureIdVerificationDocumentsContext(postbox: self.context.account.postbox, network: self.context.account.network, context: self.secureIdContext, update: { id, state in -``` - -Replace with: - -```swift - self.uploadContext = SecureIdVerificationDocumentsContext(engine: self.context.engine, context: self.secureIdContext, update: { id, state in -``` - -Two removed arguments (`postbox:`, `network:`) collapse into one new argument (`engine:`). The closure body inside `update: { id, state in ... }` is unchanged. - -No other line in this file changes. The file continues to `import Postbox` for unrelated reasons — this is expected, do not remove. - ---- - -## Task 5: Full build and commit C1 - -- [ ] **Step 1: Run the full project build** - -Run the build command from the header. Expected: clean success. - -Typical failure modes and fixes (do them inline — do not split): - -- **"cannot convert value of type 'Postbox' to expected argument type 'TelegramEngine'"** — a call site was missed. Re-grep both `uploadSecureIdFile(` and `SecureIdVerificationDocumentsContext(` across the repo. -- **"cannot convert value of type 'MediaResource' to expected argument type 'EngineMediaResource'"** — Task 3 Step 4's `EngineMediaResource(info.resource)` wrap was missed. -- **"use of unresolved identifier 'Network'"** or **"use of unresolved identifier 'Postbox'"** inside `SecureIdVerificationDocumentsContext.swift`** — Tasks 3 Steps 2–3 or 4 weren't fully applied. -- **"missing argument for parameter 'engine'"** — the Task 4 call site wasn't updated. - -Re-run the build after each fix. - -- [ ] **Step 2: Stage the three files** - -```bash -git add \ - submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift \ - submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift \ - submodules/PassportUI/Sources/SecureIdDocumentFormControllerNode.swift -``` - -- [ ] **Step 3: Verify diff scope** - -```bash -git diff --staged --stat -``` - -Expected: exactly 3 files staged. Approximate line changes: -- `UploadSecureIdFile.swift`: ~3 lines (signature + 2 body sites). -- `SecureIdVerificationDocumentsContext.swift`: ~8 lines (1 import removed, stored props, constructor, call site). -- `SecureIdDocumentFormControllerNode.swift`: 1 line. - -- [ ] **Step 4: Commit C1** - -```bash -git commit -m "$(cat <<'EOF' -SecureId: migrate uploadSecureIdFile + context to TelegramEngine - -uploadSecureIdFile's public signature now takes engine: TelegramEngine -and resource: EngineMediaResource instead of raw postbox: Postbox + -network: Network + MediaResource. The function body accesses raw -Postbox types via engine.account.postbox / engine.account.network -(internal Postbox-facing layer stays raw per CLAUDE.md). - -SecureIdVerificationDocumentsContext refactored in lockstep: stores -engine: TelegramEngine instead of raw postbox + network, drops -import Postbox. The one instantiation site in -SecureIdDocumentFormControllerNode updates to pass engine: -self.context.engine. - -Wave-5 of the Postbox -> TelegramEngine refactor; completes the last -explicitly-named future-wave candidate. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Verify branch state** - -```bash -git log --oneline master..HEAD -``` - -Expected (newest at top): -- ` SecureId: migrate uploadSecureIdFile + context to TelegramEngine` -- `b7a1a5dfb0 docs(spec): wave-5 uploadSecureIdFile facade + SecureId context migration` - ---- - -## Task 6: Update CLAUDE.md tally and commit C2 - -- [ ] **Step 1: Add `SecureIdVerificationDocumentsContext` to the Postbox-free tally** - -Open `CLAUDE.md`. Find the "Modules currently free of `import Postbox` (running tally)" section. Add `- SecureIdVerificationDocumentsContext (wave 5)` as the last bullet in the list, immediately after `- SaveToCameraRoll (wave 3)`: - -```markdown -- `MapResourceToAvatarSizes` (wave 2) -- `SaveToCameraRoll` (wave 3) -- `SecureIdVerificationDocumentsContext` (wave 5) -``` - -- [ ] **Step 2: Add a "Wave 5 outcome" subsection** - -Still in `CLAUDE.md`, find the "Wave 4 outcome (2026-04-18)" block. Insert a new "Wave 5 outcome" subsection **after** Wave 4 and **before** "Modules currently free of `import Postbox`": - -```markdown -### Wave 5 outcome (2026-04-18) - -Completes the last explicitly-named future-wave candidate from the wave-2 final review. - -`uploadSecureIdFile(context: SecureIdAccessContext, postbox: Postbox, network: Network, resource: MediaResource)` migrated in place to `(context:, engine: TelegramEngine, resource: EngineMediaResource)`. Function body accesses raw Postbox types via `engine.account.postbox` / `engine.account.network` (internal Postbox-facing layer stays raw per the standing rule). - -1 consumer submodule fully de-Postboxed: `SecureIdVerificationDocumentsContext` (PassportUI/Sources). Signature changed from `(postbox: Postbox, network: Network, context: SecureIdAccessContext, update: ...)` to `(engine: TelegramEngine, context: SecureIdAccessContext, update: ...)`; stored props collapsed into a single `engine: TelegramEngine` field. One instantiation site updated in the same commit. - -After this wave, the "Known future-wave candidates" list contains only the 4 permanently-blocked classes conforming to `TelegramMediaResource`. - -Plan: `docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-5.md` -``` - -- [ ] **Step 3: Remove the `uploadSecureIdFile` bullet from "Known future-wave candidates"** - -Still in `CLAUDE.md`, find the "Known future-wave candidates" list. Delete this bullet entirely: - -```markdown -- `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift: public func uploadSecureIdFile(…, postbox: Postbox, …, resource: MediaResource)` — rule-2-sensitive (umbrella-type leak). Needs a paired wave with its caller(s). -``` - -Do not touch the remaining bullet about permanently-blocked classes. - -- [ ] **Step 4: Commit C2** - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -CLAUDE.md: record wave-5 outcome - -Adds SecureIdVerificationDocumentsContext to the Postbox-free module -tally, documents the uploadSecureIdFile facade migration, and removes -the uploadSecureIdFile bullet from "Known future-wave candidates". -After this wave, the candidate list contains only the 4 permanently- -blocked TelegramMediaResource-conforming classes. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Verify final branch state** - -```bash -git log --oneline master..HEAD -``` - -Expected: -- ` CLAUDE.md: record wave-5 outcome` -- ` SecureId: migrate uploadSecureIdFile + context to TelegramEngine` -- `b7a1a5dfb0 docs(spec): wave-5 uploadSecureIdFile facade + SecureId context migration` - ---- - -## Success criteria - -- `uploadSecureIdFile`'s public signature references neither `Postbox`, `Network`, nor `MediaResource`. -- `SecureIdVerificationDocumentsContext.swift` does not contain `import Postbox`. -- Full build succeeds in `debug_sim_arm64`. -- `grep -l "import Postbox" submodules/PassportUI/Sources/SecureIdVerificationDocumentsContext.swift` returns no match. -- `CLAUDE.md`'s "Known future-wave candidates" list no longer mentions `uploadSecureIdFile`; the Postbox-free running tally includes `SecureIdVerificationDocumentsContext (wave 5)`. -- Branch `refactor/postbox-to-engine-wave-5` contains 3 commits above `master`: 1 doc (spec) + 1 code (C1) + 1 tally (C2). diff --git a/docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md b/docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md deleted file mode 100644 index 2693f19732..0000000000 --- a/docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md +++ /dev/null @@ -1,374 +0,0 @@ -# Postbox → TelegramEngine Wave 6 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Speculatively drop `import Postbox` from every consumer file where a plain `^import Postbox$` line appears, run a full project build, restore the import on files that fail to compile, iterate up to 3 times, commit surviving drops as one atomic commit. Then land a CLAUDE.md update with the outcome and permanent methodology guidance. - -**Architecture:** Two commits on branch `refactor/postbox-to-engine-wave-6`. C1 is the atomic batch deletion whose diff is N single-line removals (build-verified). C2 is a docs update that (a) records the outcome and (b) codifies the sweep methodology under "Wave-selection guidance" so future sweeps can be triggered directly. The project build is the safety net — anything that compiles after restoration is definitionally safe. - -**Tech Stack:** Swift / Bazel. No unit tests — verification is a full project build. - -**Spec:** [docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md](docs/superpowers/specs/2026-04-19-postbox-to-telegramengine-wave-6-design.md) - -**Build command** (use for every "full build" step): - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 -``` - -For background execution (recommended given build length), use `run_in_background: true` from the controller session. Do not let a subagent spawn the build — when the subagent returns the process orphans. The controller owns every build invocation in this wave. - ---- - -## Task 1: Generate and record the candidate list - -Read-only setup. No code changes yet. - -- [ ] **Step 1: Generate the candidate list** - -```bash -grep -rl "^import Postbox$" submodules --include="*.swift" \ - | grep -vE "/(TelegramCore|Postbox|TelegramApi)/" \ - | sort > /tmp/wave-6-candidates.txt -wc -l /tmp/wave-6-candidates.txt -``` - -Expected: a count somewhere between 100 and 400. Record the exact number — call it `N_candidates`. If the count is outside that range, stop and investigate: either the grep is too narrow (missing `@_exported` etc. ought to be rare) or too broad (accidentally matching TelegramCore). - -- [ ] **Step 2: Snapshot baseline** - -The snapshot is implicit: every candidate file is at branch HEAD, so `git checkout -- ` always restores the pre-sweep content. Verify the working tree is clean: - -```bash -git status --short | grep -v '^??' | grep -v sourcekit-bazel-bsp -``` - -Expected: empty output. (The `sourcekit-bazel-bsp` submodule shows as modified across the whole repo; that's pre-existing and orthogonal.) If there are any other unstaged changes, commit or stash them before proceeding. - -- [ ] **Step 3: Confirm branch and HEAD** - -```bash -git branch --show-current -git log --oneline -3 -``` - -Expected: -- current branch: `refactor/postbox-to-engine-wave-6` -- top commit: the wave-6 spec commit. - ---- - -## Task 2: Speculative drop pass - -Mutates all candidate files. No commit yet. - -- [ ] **Step 1: Drop `import Postbox` from every candidate** - -```bash -while IFS= read -r f; do - /usr/bin/sed -i '' '/^import Postbox$/d' "$f" -done < /tmp/wave-6-candidates.txt -``` - -macOS `sed` requires the `''` after `-i` (BSD flavor). - -- [ ] **Step 2: Verify every candidate had exactly one line removed** - -```bash -git diff --stat | wc -l -``` - -Expected: `N_candidates + 1` (one line per file in `--stat` output, plus the summary line). - -```bash -git diff --stat | awk '{print $3}' | grep -v deletion | head -5 -``` - -Expected: each shown entry is `1` (one insertion, zero counted since all are single-line deletes). If any file shows more than 1 line changed, something went wrong — investigate. - -- [ ] **Step 3: Confirm no `@_exported` lines were accidentally touched** - -```bash -grep -r "@_exported import Postbox" submodules --include="*.swift" | head -5 -``` - -If this returns results, those lines must still be intact — verify. The regex used in Step 1 only matches bare `^import Postbox$`, so `@_exported import Postbox` is untouched. This step is a sanity check. - ---- - -## Task 3: Iteration 1 — first build, parse errors, restore failing files - -- [ ] **Step 1: Run the full project build (iteration 1)** - -Run the build command from the header. Expected: many errors — this is by design. Capture stderr to the build output file. - -Watch the tail of the output file for either `INFO: Build completed successfully` (rare: means zero imports were needed) or a cascade of compile errors (expected). - -- [ ] **Step 2: Extract failing files from the build output** - -```bash -BUILD_OUT=/private/tmp/claude-501/-Users-ali-build-telegram-telegram-ios/5d9b3268-5c9f-45fc-bd4e-87cac5361498/tasks/.output -grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \ - | awk -F: '{print $1}' \ - | sort -u > /tmp/wave-6-failing.txt -wc -l /tmp/wave-6-failing.txt -``` - -The task-id comes from the background Bash tool's output file. Substitute the actual `/private/tmp/claude-501/.../.output` path. - -Sanity-check the content: - -```bash -head -3 /tmp/wave-6-failing.txt -``` - -Every line should be a path under `submodules/` that appears in `/tmp/wave-6-candidates.txt`. If any line is from `TelegramCore`, `Postbox`, or `TelegramApi`, the sweep has cascaded beyond the candidate set — halt and investigate. - -- [ ] **Step 3: Validate error types** - -```bash -grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \ - | head -10 -``` - -Expected error patterns: -- `cannot find type 'X' in scope` -- `use of unresolved identifier 'X'` -- `cannot find 'X' in scope` -- `reference to invalid associated type 'X' of type 'Y'` (occasional) - -If you see `no such module 'Postbox'` or errors unrelated to missing Postbox symbols (e.g., codesign failures, Bazel graph errors), halt and investigate — those are not the sweep's signal. - -- [ ] **Step 4: Restore `import Postbox` on failing files** - -```bash -while IFS= read -r f; do - git checkout -- "$f" -done < /tmp/wave-6-failing.txt -``` - -- [ ] **Step 5: Verify restoration** - -```bash -git diff --stat | wc -l -``` - -Expected: `N_candidates - N_failing + 1` lines in `--stat` output (one per still-modified file plus summary). The count should be lower than Task 2 Step 2's count by exactly `N_failing`. - ---- - -## Task 4: Iteration 2 — rebuild, parse new errors, restore - -- [ ] **Step 1: Run the full project build (iteration 2)** - -Run the build command again. Expected: ideally clean success. If errors persist, it's because restoring some files in iteration 1 removed a symbol that another file (still in the candidate set with import dropped) needed transitively via that symbol's module-level re-export. - -Watch for `INFO: Build completed successfully`. If found, proceed to Task 6 (skipping Task 5). If errors persist, continue with Step 2. - -- [ ] **Step 2: Extract failing files** - -```bash -BUILD_OUT=/private/tmp/claude-501/-Users-ali-build-telegram-telegram-ios/5d9b3268-5c9f-45fc-bd4e-87cac5361498/tasks/.output -grep -E "^submodules/.*\.swift:[0-9]+:[0-9]+: error:" "$BUILD_OUT" \ - | awk -F: '{print $1}' \ - | sort -u > /tmp/wave-6-failing-2.txt -wc -l /tmp/wave-6-failing-2.txt -``` - -- [ ] **Step 3: Restore** - -```bash -while IFS= read -r f; do - git checkout -- "$f" -done < /tmp/wave-6-failing-2.txt -``` - -- [ ] **Step 4: Decision point** - -If `wc -l /tmp/wave-6-failing-2.txt` is 0, the iteration-2 rebuild actually succeeded — proceed to Task 6. If it's greater than 0, proceed to Task 5 for iteration 3. - ---- - -## Task 5: Iteration 3 — final rebuild - -- [ ] **Step 1: Run the full project build (iteration 3)** - -Run the build command again. If this iteration does not complete successfully, the sweep has failed the stability test. - -- [ ] **Step 2: Clean-success check** - -Expected: `INFO: Build completed successfully`. - -If successful, proceed to Task 6. - -If a third iteration of errors appears, **abandon the wave**: - -```bash -git checkout -- . -git status --short -``` - -Working tree should now be clean (modulo the pre-existing sourcekit-bazel-bsp submodule marker). Do not commit. Skip Task 6. Jump straight to an updated Task 7 that records the failed attempt in CLAUDE.md instead of a success outcome, and document what kind of errors surfaced so a future attempt can plan around them. - ---- - -## Task 6: Commit C1 — build-verified batch drop - -- [ ] **Step 1: Compute the final count** - -```bash -git diff --stat | tail -1 -``` - -Expected: something like ` N files changed, 0 insertions(+), N deletions(-)` where N is the number of files that survived the sweep. Record this count as `N_dropped`. - -- [ ] **Step 2: Spot-check a few diffs** - -```bash -git diff | grep -E "^-import Postbox$" | wc -l -``` - -Expected: `N_dropped` (every surviving diff is a single-line `-import Postbox` removal). - -```bash -git diff | grep -E "^\+" | grep -v "^+++" | head -``` - -Expected: no output. (The sweep only removes lines; it never adds any.) - -- [ ] **Step 3: Stage all changes** - -```bash -git add -u -``` - -`-u` stages only files that are already tracked and modified. No need to enumerate each file — the sweep touched many and they're all known to git. - -- [ ] **Step 4: Commit** - -```bash -N_DROPPED=$(git diff --staged --stat | tail -1 | awk '{print $1}') -git commit -m "$(cat < -EOF -)" -``` - -- [ ] **Step 5: Verify branch state** - -```bash -git log --oneline master..HEAD -``` - -Expected: -- ` Drop unused import Postbox from N consumer files` -- `816e7699ec docs(spec): wave-6 unused import Postbox batch sweep` - ---- - -## Task 7: CLAUDE.md — record outcome and add permanent sweep methodology - -- [ ] **Step 1: Add Wave 6 outcome subsection** - -Open `CLAUDE.md`. Find the "Wave 5 outcome (2026-04-19)" block. Insert a new "Wave 6 outcome (2026-04-19)" subsection immediately after Wave 5 and before "Modules currently free of `import Postbox`": - -```markdown -### Wave 6 outcome (2026-04-19) - -First unused-import sweep. Ran the speculative-drop + build-verify methodology (see "Unused-import sweeps" under Wave-selection guidance): dropped `import Postbox` from every consumer file where a plain `^import Postbox$` appeared (out of ~N_CANDIDATES candidates), rebuilt, restored the import on failures, iterated. N_DROPPED drops survived. - -No behavior change; zero facade migrations in this wave. Running tally updated for any modules whose last `import Postbox`-bearing file was swept (see the per-module list below). - -Plan: `docs/superpowers/plans/2026-04-19-postbox-to-telegramengine-wave-6.md` -``` - -Replace `N_CANDIDATES` and `N_DROPPED` with the actual numbers from Task 1 Step 1 and Task 6 Step 1. If the wave was abandoned (see Task 5 Step 2), replace the outcome text with a failed-attempt description instead: what iteration the sweep stalled at and what error category. - -- [ ] **Step 2: Add permanent "Unused-import sweeps" subsection under Wave-selection guidance** - -Still in `CLAUDE.md`, find the "Wave-selection guidance" block. Insert the following new subsection at the end of that block (immediately before "### Wave 1 outcome"): - -```markdown -**Unused-import sweeps are a valid wave shape.** After a round of facade migrations, consumer files accumulate `import Postbox` lines whose last semantic use was removed. Periodically sweep these: - -1. `grep -rl "^import Postbox$" submodules --include="*.swift" | grep -vE "/(TelegramCore|Postbox|TelegramApi)/"` generates the candidate list. -2. `sed -i '' '/^import Postbox$/d' ` (BSD sed) speculatively drops the import from every candidate. -3. Run the full project build. Swift compile errors (`::: error: cannot find type 'X'`) identify files that need the import restored via `git checkout -- `. -4. Rebuild. Iterate up to 3 times. Only restore files from the candidate set — if errors surface in `TelegramCore`, `Postbox`, or `TelegramApi`, halt and investigate (cascading breakage). -5. Commit the surviving drops as one atomic commit. - -Re-run this after every 2–3 facade-migration waves. First run: wave 6. -``` - -- [ ] **Step 3: Update "Modules currently free of `import Postbox`" tally** - -For each module in `submodules/` that has **no** remaining `import Postbox` after this wave, add a bullet under "Modules currently free of `import Postbox` (running tally)". Determine this list with: - -```bash -for d in submodules/*/; do - mod=$(basename "$d") - if [ -d "$d/Sources" ]; then - count=$(grep -rlE "^(@_exported )?import Postbox" "$d/Sources" --include="*.swift" 2>/dev/null | wc -l) - if [ "$count" -eq 0 ]; then - # Check this module isn't already in CLAUDE.md's tally - if ! grep -qF "\`$mod\`" CLAUDE.md; then - echo "$mod" - fi - fi - fi -done -``` - -Each printed module becomes a new bullet like `- \`\` (wave 6)` in the list. - -If the output is empty, no new module-level additions — individual file drops across multiple mixed modules aren't tally-eligible. That's fine, the Wave-6 outcome subsection still records the raw count. - -- [ ] **Step 4: Commit** - -```bash -git add CLAUDE.md -git commit -m "$(cat <<'EOF' -CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology - -Adds the wave-6 outcome subsection with the candidate/drop counts, -documents the speculative-drop + build-verify methodology as -permanent guidance under wave-selection so future waves can re-run -the sweep directly, and updates the Postbox-free running tally for -any modules whose last import Postbox file was swept in this wave. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Verify final branch state** - -```bash -git log --oneline master..HEAD -``` - -Expected (newest first): -- ` CLAUDE.md: record wave-6 outcome and unused-import-sweep methodology` -- ` Drop unused import Postbox from N consumer files` -- `816e7699ec docs(spec): wave-6 unused import Postbox batch sweep` - ---- - -## Success criteria - -- At least one `import Postbox` line has been removed from at least one consumer file, build-verified. -- Full build succeeds in `debug_sim_arm64`. -- `CLAUDE.md` has a "Wave 6 outcome (2026-04-19)" subsection with actual numeric results. -- `CLAUDE.md`'s "Wave-selection guidance" section has a new permanent "Unused-import sweeps" bullet list that describes the methodology for future re-runs. -- `CLAUDE.md`'s "Modules currently free of `import Postbox`" running tally includes any newly-fully-clean modules (if any). -- Branch `refactor/postbox-to-engine-wave-6` contains 3 commits above `master`: 1 doc (spec) + 1 code (C1 batch drop) + 1 tally (C2). diff --git a/docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md b/docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md deleted file mode 100644 index 702cbe807a..0000000000 --- a/docs/superpowers/plans/2026-04-20-decrypt-match-python-port.md +++ /dev/null @@ -1,539 +0,0 @@ -# Pure-Python port of fastlane match `decrypt.rb` — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the Ruby-based fastlane match decryption (`build-system/decrypt.rb` shelled from `BuildConfiguration.py:110`) with a self-contained Python 3 implementation using only the standard library. - -**Architecture:** Rewrite `build-system/Make/DecryptMatch.py` from scratch as a pure-Python AES-256 implementation. Covers V1 (CBC via `EVP_BytesToKey` with MD5→SHA256 fallback) and V2 (GCM with PBKDF2-derived key/iv/AAD + auth tag). `BuildConfiguration.py` calls the existing `decrypt_match_data(source, destination, password)` entry point directly instead of shelling out to Ruby. `decrypt.rb` is deleted. - -**Tech Stack:** Python 3 stdlib only — `hashlib` (MD5 / SHA256 / PBKDF2-HMAC), `base64`. - ---- - -## File structure - -- **Rewrite (not edit):** `build-system/Make/DecryptMatch.py` — new file replacing the broken placeholder. Single module containing: AES-256 primitives, `EVP_BytesToKey`, CBC decrypt, GCM decrypt (with GHASH + CTR), `MatchDataEncryption` dispatcher, `decrypt_match_data` public entry, `__main__` CLI. -- **Modify:** `build-system/Make/BuildConfiguration.py:103-118` — swap `os.system('ruby …')` for a direct Python call. -- **Delete:** `build-system/decrypt.rb`. - ---- - -## Task 1: Rewrite `build-system/Make/DecryptMatch.py` - -**Files:** -- Modify (rewrite): `build-system/Make/DecryptMatch.py` - -- [ ] **Step 1.1: Replace the file contents entirely** - -Overwrite `build-system/Make/DecryptMatch.py` with the following. This is the full file — no other changes to this module in later tasks. - -```python -import base64 -import hashlib - - -# FIPS-197 AES S-box and inverse S-box. -_SBOX = bytes.fromhex( - "637c777bf26b6fc53001672bfed7ab76" - "ca82c97dfa5947f0add4a2af9ca472c0" - "b7fd9326363ff7cc34a5e5f171d83115" - "04c723c31896059a071280e2eb27b275" - "09832c1a1b6e5aa0523bd6b329e32f84" - "53d100ed20fcb15b6acbbe394a4c58cf" - "d0efaafb434d338545f9027f503c9fa8" - "51a3408f929d38f5bcb6da2110fff3d2" - "cd0c13ec5f974417c4a77e3d645d1973" - "60814fdc222a908846eeb814de5e0bdb" - "e0323a0a4906245cc2d3ac629195e479" - "e7c8376d8dd54ea96c56f4ea657aae08" - "ba78252e1ca6b4c6e8dd741f4bbd8b8a" - "703eb5664803f60e613557b986c11d9e" - "e1f8981169d98e949b1e87e9ce5528df" - "8ca1890dbfe6426841992d0fb054bb16" -) - -_INV_SBOX = bytes.fromhex( - "52096ad53036a538bf40a39e81f3d7fb" - "7ce339829b2fff87348e4344c4dee9cb" - "547b9432a6c2233dee4c950b42fac34e" - "082ea16628d924b2765ba2496d8bd125" - "72f8f66486689816d4a45ccc5d65b692" - "6c704850fdedb9da5e154657a78d9d84" - "90d8ab008cbcd30af7e45805b8b34506" - "d02c1e8fca3f0f02c1afbd0301138a6b" - "3a9111414f67dcea97f2cfcef0b4e673" - "96ac7422e7ad3585e2f937e81c75df6e" - "47f11a711d29c5896fb7620eaa18be1b" - "fc563e4bc6d279209adbc0fe78cd5af4" - "1fdda8338807c731b11210592780ec5f" - "60517fa919b54a0d2de57a9f93c99cef" - "a0e03b4dae2af5b0c8ebbb3c83539961" - "172b047eba77d626e169146355210c7d" -) - -_RCON = bytes.fromhex("01020408102040801b36") - - -def _xtime(a): - return (((a << 1) ^ 0x1b) & 0xff) if (a & 0x80) else (a << 1) - - -def _gf_mul(a, b): - r = 0 - for _ in range(8): - if b & 1: - r ^= a - b >>= 1 - a = _xtime(a) - return r - - -def _key_expansion_256(key): - # AES-256: Nk=8, Nr=14, total 4 * (Nr + 1) = 60 words = 240 bytes. - assert len(key) == 32 - w = bytearray(240) - w[:32] = key - i = 32 - while i < 240: - t = bytearray(w[i - 4:i]) - if i % 32 == 0: - t = bytearray([t[1], t[2], t[3], t[0]]) - for j in range(4): - t[j] = _SBOX[t[j]] - t[0] ^= _RCON[i // 32 - 1] - elif i % 32 == 16: - for j in range(4): - t[j] = _SBOX[t[j]] - for j in range(4): - w[i + j] = w[i - 32 + j] ^ t[j] - i += 4 - return [bytes(w[r * 16:(r + 1) * 16]) for r in range(15)] - - -def _add_round_key(state, rk): - return bytes(s ^ k for s, k in zip(state, rk)) - - -def _sub_bytes(state): - return bytes(_SBOX[b] for b in state) - - -def _inv_sub_bytes(state): - return bytes(_INV_SBOX[b] for b in state) - - -# Column-major state: state[r + 4 * c], r = 0..3 (row), c = 0..3 (column). -def _shift_rows(state): - s = bytearray(state) - s[1], s[5], s[9], s[13] = s[5], s[9], s[13], s[1] - s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6] - s[3], s[7], s[11], s[15] = s[15], s[3], s[7], s[11] - return bytes(s) - - -def _inv_shift_rows(state): - s = bytearray(state) - s[1], s[5], s[9], s[13] = s[13], s[1], s[5], s[9] - s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6] - s[3], s[7], s[11], s[15] = s[7], s[11], s[15], s[3] - return bytes(s) - - -def _mix_columns(state): - s = bytearray(16) - for c in range(4): - a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3] - s[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3 - s[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3 - s[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3) - s[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3) - return bytes(s) - - -def _inv_mix_columns(state): - s = bytearray(16) - for c in range(4): - a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3] - s[4 * c] = _gf_mul(a0, 0x0e) ^ _gf_mul(a1, 0x0b) ^ _gf_mul(a2, 0x0d) ^ _gf_mul(a3, 0x09) - s[4 * c + 1] = _gf_mul(a0, 0x09) ^ _gf_mul(a1, 0x0e) ^ _gf_mul(a2, 0x0b) ^ _gf_mul(a3, 0x0d) - s[4 * c + 2] = _gf_mul(a0, 0x0d) ^ _gf_mul(a1, 0x09) ^ _gf_mul(a2, 0x0e) ^ _gf_mul(a3, 0x0b) - s[4 * c + 3] = _gf_mul(a0, 0x0b) ^ _gf_mul(a1, 0x0d) ^ _gf_mul(a2, 0x09) ^ _gf_mul(a3, 0x0e) - return bytes(s) - - -def _aes_encrypt_block(block, round_keys): - state = _add_round_key(block, round_keys[0]) - for r in range(1, 14): - state = _sub_bytes(state) - state = _shift_rows(state) - state = _mix_columns(state) - state = _add_round_key(state, round_keys[r]) - state = _sub_bytes(state) - state = _shift_rows(state) - state = _add_round_key(state, round_keys[14]) - return state - - -def _aes_decrypt_block(block, round_keys): - state = _add_round_key(block, round_keys[14]) - for r in range(13, 0, -1): - state = _inv_shift_rows(state) - state = _inv_sub_bytes(state) - state = _add_round_key(state, round_keys[r]) - state = _inv_mix_columns(state) - state = _inv_shift_rows(state) - state = _inv_sub_bytes(state) - state = _add_round_key(state, round_keys[0]) - return state - - -def _evp_bytes_to_key(password, salt, hash_name, key_len=32, iv_len=16): - # OpenSSL EVP_BytesToKey with count=1, matching Ruby's - # Cipher#pkcs5_keyivgen(password, salt, 1, hash). - if isinstance(password, str): - password = password.encode('utf-8') - required = key_len + iv_len - material = b"" - prev = b"" - while len(material) < required: - h = hashlib.new(hash_name) - h.update(prev + password + salt) - prev = h.digest() - material += prev - return material[:key_len], material[key_len:key_len + iv_len] - - -def _aes_cbc_decrypt(ciphertext, key, iv): - if len(ciphertext) == 0 or len(ciphertext) % 16 != 0: - raise ValueError("V1 ciphertext length must be a non-zero multiple of 16") - round_keys = _key_expansion_256(key) - out = bytearray() - prev = iv - for i in range(0, len(ciphertext), 16): - block = ciphertext[i:i + 16] - decrypted = _aes_decrypt_block(block, round_keys) - out.extend(bytes(d ^ p for d, p in zip(decrypted, prev))) - prev = block - pad = out[-1] - if pad < 1 or pad > 16 or not all(b == pad for b in out[-pad:]): - raise ValueError("V1 PKCS#7 padding check failed") - return bytes(out[:-pad]) - - -def _ghash(h_bytes, data): - # GHASH over GF(2^128) with reduction polynomial x^128 + x^7 + x^2 + x + 1, - # using GCM's bit-reversed convention (top-bit-first when encoded as bytes). - h = int.from_bytes(h_bytes, 'big') - y = 0 - reduction = 0xe1 << 120 - for i in range(0, len(data), 16): - block = data[i:i + 16].ljust(16, b"\x00") - y ^= int.from_bytes(block, 'big') - z = 0 - v = y - for bit in range(127, -1, -1): - if (h >> bit) & 1: - z ^= v - if v & 1: - v = (v >> 1) ^ reduction - else: - v >>= 1 - y = z - return y.to_bytes(16, 'big') - - -def _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag): - if len(iv) != 12: - raise ValueError("V2 requires a 96-bit IV") - round_keys = _key_expansion_256(key) - H = _aes_encrypt_block(b"\x00" * 16, round_keys) - j0 = iv + b"\x00\x00\x00\x01" - - plaintext = bytearray() - j0_int = int.from_bytes(j0, 'big') - mask32 = (1 << 32) - 1 - counter_high = j0_int & ~mask32 - counter_low = j0_int & mask32 - n_blocks = (len(ciphertext) + 15) // 16 - for i in range(n_blocks): - counter_low = (counter_low + 1) & mask32 - ctr_bytes = (counter_high | counter_low).to_bytes(16, 'big') - keystream = _aes_encrypt_block(ctr_bytes, round_keys) - block = ciphertext[i * 16:(i + 1) * 16] - plaintext.extend(bytes(c ^ k for c, k in zip(block, keystream[:len(block)]))) - - aad_pad = b"\x00" * ((16 - len(aad) % 16) % 16) - ct_pad = b"\x00" * ((16 - len(ciphertext) % 16) % 16) - length_block = (len(aad) * 8).to_bytes(8, 'big') + (len(ciphertext) * 8).to_bytes(8, 'big') - s = _ghash(H, aad + aad_pad + ciphertext + ct_pad + length_block) - e_j0 = _aes_encrypt_block(j0, round_keys) - computed_tag = bytes(a ^ b for a, b in zip(s, e_j0)) - if computed_tag != auth_tag: - raise ValueError("V2 GCM auth tag mismatch") - return bytes(plaintext) - - -_V1_PREFIX = b"Salted__" -_V2_PREFIX = b"match_encrypted_v2__" - - -def _decrypt_stored(stored_data, password): - if stored_data.startswith(_V2_PREFIX): - salt = stored_data[20:28] - auth_tag = stored_data[28:44] - ciphertext = stored_data[44:] - material = hashlib.pbkdf2_hmac( - 'sha256', - password.encode('utf-8'), - salt, - 10_000, - dklen=32 + 12 + 24, - ) - key = material[0:32] - iv = material[32:44] - aad = material[44:68] - return _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag) - if stored_data.startswith(_V1_PREFIX): - salt = stored_data[8:16] - ciphertext = stored_data[16:] - try: - key, iv = _evp_bytes_to_key(password, salt, 'md5', 32, 16) - return _aes_cbc_decrypt(ciphertext, key, iv) - except Exception: - key, iv = _evp_bytes_to_key(password, salt, 'sha256', 32, 16) - return _aes_cbc_decrypt(ciphertext, key, iv) - raise ValueError("Unrecognized fastlane match payload (missing V1 'Salted__' or V2 'match_encrypted_v2__' prefix)") - - -def decrypt_match_data(source_path: str, destination_path: str, password: str): - with open(source_path, 'rb') as f: - raw = f.read() - stored_data = base64.b64decode(raw) - decrypted = _decrypt_stored(stored_data, password) - with open(destination_path, 'wb') as f: - f.write(decrypted) - - -if __name__ == '__main__': - import sys - if len(sys.argv) != 4: - print('Usage: DecryptMatch.py ') - sys.exit(1) - decrypt_match_data(source_path=sys.argv[2], destination_path=sys.argv[3], password=sys.argv[1]) -``` - ---- - -## Task 2: Smoke-test the AES-256 block primitive (FIPS-197 Appendix C.3) - -**Files:** -- No changes. One-liner shell command to validate the just-written primitive. - -- [ ] **Step 2.1: Run the FIPS-197 C.3 known-answer test** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -python3 -c " -import sys -sys.path.insert(0, 'build-system/Make') -from DecryptMatch import _key_expansion_256, _aes_encrypt_block, _aes_decrypt_block -key = bytes.fromhex('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f') -pt = bytes.fromhex('00112233445566778899aabbccddeeff') -expected = bytes.fromhex('8ea2b7ca516745bfeafc49904b496089') -rks = _key_expansion_256(key) -assert _aes_encrypt_block(pt, rks) == expected, 'encrypt failed' -assert _aes_decrypt_block(expected, rks) == pt, 'decrypt failed' -print('AES-256 FIPS-197 C.3 OK') -" -``` - -Expected output: `AES-256 FIPS-197 C.3 OK`. If this fails, the AES primitive is broken — re-read Task 1's code and fix before proceeding. - ---- - -## Task 3: Validate V2 decryption on real encrypted files - -**Files:** -- No changes. Decrypt real samples with the new Python and verify each output is a cryptographically-valid Apple-signed artifact. - -**Success criteria:** the decrypted `.mobileprovision` files verify under `openssl smime -verify` and parse as valid plists. A CMS signature covers every byte of the payload, so successful verification is equivalent to bit-exact decryption — any wrong byte anywhere would break the signature. This is a stronger check than diffing against another implementation, and it matches what `BuildConfiguration.copy_profiles_from_directory` does on every profile in the real build, so passing here means the port is production-ready. - -The encrypted repo is at `~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/`. Repo password: `sluchainost` (per the hard-coded value in the file Task 1 replaced). - -> NOTE: Do not attempt a byte-for-byte comparison against `ruby build-system/decrypt.rb`. Ruby's OpenSSL binding on macOS LibreSSL 3.3.6 fails on `cipher.auth_data=` with `couldn't set additional authenticated data`, so the legacy script cannot decrypt V2 at all on current macOS. (This is likely why the build accumulated a broken aspirational Python port in the first place.) Signature verification of the Python output is the authoritative check. - -- [ ] **Step 3.1: Decrypt one sample file** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -SAMPLE=~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/Development_org.telegram.TelegramInternal.BroadcastUpload.mobileprovision -python3 build-system/Make/DecryptMatch.py sluchainost "$SAMPLE" /tmp/match-py.bin -shasum -a 256 /tmp/match-py.bin -``` - -Expected: `match-py.bin` is non-empty; a sha256 is printed. - -- [ ] **Step 3.2: Verify the output is a valid Apple-signed provisioning profile** - -```bash -openssl smime -inform der -verify -noverify -in /tmp/match-py.bin | plutil -lint - -``` - -Expected: `openssl smime` prints `Verification successful` (or similar; exit code 0 is what matters), and `plutil` reports `OK`. Either failure means the decryption is corrupt — STOP and report BLOCKED with the exact openssl/plutil output. - -- [ ] **Step 3.3: Spot-check remaining V2 files decrypt without error** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -ENCRYPTED=~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development -for f in "$ENCRYPTED"/*.mobileprovision; do - python3 build-system/Make/DecryptMatch.py sluchainost "$f" /tmp/match-check.bin \ - && openssl smime -inform der -verify -noverify -in /tmp/match-check.bin > /dev/null 2>&1 \ - && echo "OK $(basename "$f")" \ - || echo "FAIL $(basename "$f")" -done -``` - -Expected: every line starts with `OK`. Any `FAIL` line means that file's decryption is corrupt — STOP and report BLOCKED. - ---- - -## Task 4: Commit the rewrite - -**Files:** -- Commit `build-system/Make/DecryptMatch.py` only. - -- [ ] **Step 4.1: Stage and commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/Make/DecryptMatch.py -git commit -m "$(cat <<'EOF' -DecryptMatch: pure-Python AES-256 port of decrypt.rb - -Implements fastlane match V1 (AES-256-CBC via EVP_BytesToKey with -MD5 default and SHA256 fallback) and V2 (AES-256-GCM with PBKDF2- -derived key/IV/AAD + auth tag) using only Python stdlib. Validated -by decrypting every V2 .mobileprovision in the repo and confirming -each output verifies under openssl smime + plutil -lint as a valid -Apple-signed artifact. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Expected: commit created cleanly. - ---- - -## Task 5: Switch `BuildConfiguration.py` to the Python implementation and remove `decrypt.rb` - -**Files:** -- Modify: `build-system/Make/BuildConfiguration.py:103-118` -- Delete: `build-system/decrypt.rb` - -- [ ] **Step 5.1: Swap the call site** - -Replace lines 103-118 of `build-system/Make/BuildConfiguration.py`: - -```python -def decrypt_codesigning_directory_recursively(source_base_path, destination_base_path, password): - for file_name in os.listdir(source_base_path): - source_path = source_base_path + '/' + file_name - destination_path = destination_base_path + '/' + file_name - allowed_file_extensions = ['.mobileprovision', '.cer', '.p12'] - if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions): - #print('Decrypting {} to {} with {}'.format(source_path, destination_path, password)) - os.system('ruby build-system/decrypt.rb "{password}" "{source_path}" "{destination_path}"'.format( - password=password, - source_path=source_path, - destination_path=destination_path - )) - #decrypt_match_data(source_path, destination_path, password) - elif os.path.isdir(source_path): - os.makedirs(destination_path, exist_ok=True) - decrypt_codesigning_directory_recursively(source_path, destination_path, password) -``` - -with: - -```python -def decrypt_codesigning_directory_recursively(source_base_path, destination_base_path, password): - for file_name in os.listdir(source_base_path): - source_path = source_base_path + '/' + file_name - destination_path = destination_base_path + '/' + file_name - allowed_file_extensions = ['.mobileprovision', '.cer', '.p12'] - if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions): - decrypt_match_data(source_path, destination_path, password) - elif os.path.isdir(source_path): - os.makedirs(destination_path, exist_ok=True) - decrypt_codesigning_directory_recursively(source_path, destination_path, password) -``` - -- [ ] **Step 5.2: Delete the Ruby script** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git rm build-system/decrypt.rb -``` - -- [ ] **Step 5.3: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/Make/BuildConfiguration.py -git commit -m "$(cat <<'EOF' -BuildConfiguration: use Python DecryptMatch, drop Ruby decrypt.rb - -Swap the os.system('ruby build-system/decrypt.rb ...') shell-out for -a direct decrypt_match_data() call, and delete the now-unused Ruby -script. The iOS build no longer depends on a Ruby interpreter. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Expected: commit created cleanly; `git status` shows a clean tree. - ---- - -## Task 6: End-to-end verification with `generateProject` - -**Files:** -- No changes. - -- [ ] **Step 6.1: Wipe the previously-decrypted directory so the build re-decrypts fresh** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -rm -rf ~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted -``` - -Expected: directory removed. If it did not exist, that's also fine. - -- [ ] **Step 6.2: Run the user-supplied `generateProject` command** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -source ~/.zshrc 2>/dev/null -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/build/telegram/telegram-bazel-cache \ - generateProject \ - --configurationPath ~/build/telegram/telegram-internal-tools/PrivateData/build-configurations/enterprise-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent -``` - -Expected: the command runs through project generation. The decryption step is silent on success (per `BuildConfiguration.py:decrypt_codesigning_directory_recursively`). Any decryption failure would surface downstream in `copy_profiles_from_directory` when `openssl smime -verify` chokes on a corrupted `.mobileprovision`, so a clean run proves the port is working end-to-end. - -If the command fails with a decryption-related error, revert the two commits (`git revert HEAD~1..HEAD`) and debug; otherwise the migration is complete. - -- [ ] **Step 6.3: Spot-check the generated decrypted directory** - -```bash -ls ~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/ -``` - -Expected: a populated list of `.mobileprovision` files, matching the list in the encrypted sibling directory. diff --git a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md deleted file mode 100644 index e13ee5a2d8..0000000000 --- a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-10.md +++ /dev/null @@ -1,194 +0,0 @@ -# Postbox → TelegramEngine Wave 10 Implementation Plan - -> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list. - -**Goal:** Finish the `StorageUsageScreen` consumer-module de-Postbox work started in wave 8 and continued in wave 9 by eliminating the last `import Postbox` in the module: `StorageFileListPanelComponent.swift`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case. - -**Architecture:** Replace the heterogeneous-protocol `Icon.media(Media, ...)` case with two concrete-type cases `.mediaFile(TelegramMediaFile, ...)` and `.mediaImage(TelegramMediaImage, ...)`. The split is lossless because the two construction sites already knew the concrete subtype (`imageIconValue = .media(file, representation)` vs `.media(image, representation)`), and the one consumer binding site immediately downcasted via `as? TelegramMediaFile` / `as? TelegramMediaImage` to pick which `setSignal(...)` to call. Auto-split the switch body over the two new cases; no downcast needed. Also replaces a placeholder `PeerId(namespace:..., id:...)` construction in the `measureItem` layout-measurement instance with `component.context.account.peerId` (a real, already-available `EnginePeer.Id`). - -**Tech Stack:** Swift / Bazel. No unit tests. - -**Build command:** - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError -``` - ---- - -## Scope - -**In scope:** -- `StorageFileListPanelComponent.swift`'s `Icon` enum: replace `case media(Media, TelegramMediaImageRepresentation)` with two concrete-type cases. -- Equatable rewrite: switch-over-tuple `(lhs, rhs)` pattern with id-based equality per concrete type (`lFile.fileId == rFile.fileId`, `lImage.imageId == rImage.imageId`). -- Binding rewrite at the `if case let .media(media, representation)` site (former line 448): lift `representation` via a compound `case let .mediaFile(_, representation), let .mediaImage(_, representation):` pattern, then inner switch-over-`component.icon` selects `setSignal` flavor. -- Construction rewrite at two `imageIconValue = .media(...)` sites: use the concrete case name (`.mediaFile`, `.mediaImage`). -- Placeholder `PeerId(namespace:..., id:...)` at former line 1062 (in the `measureItem` layout-measurement instance): replace with `component.context.account.peerId`. -- Remove `import Postbox` from `StorageFileListPanelComponent.swift`. - -**Out of scope:** -- None. This is the last file in the `StorageUsageScreen` module that imports Postbox; after this wave, the module is fully Postbox-free. - ---- - -## Tasks - -### Task 1: Split `Icon.media` into two concrete cases - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 103–135). - -Before: - -```swift -enum Icon: Equatable { - case fileExtension(String) - case media(Media, TelegramMediaImageRepresentation) - case audio - - static func ==(lhs: Icon, rhs: Icon) -> Bool { - switch lhs { - case let .fileExtension(value): - if case .fileExtension(value) = rhs { return true } else { return false } - case let .media(media, representation): - if case let .media(rhsMedia, rhsRepresentation) = rhs { - if media.id != rhsMedia.id { return false } - if representation != rhsRepresentation { return false } - return true - } else { return false } - case .audio: - if case .audio = rhs { return true } else { return false } - } - } -} -``` - -After: - -```swift -enum Icon: Equatable { - case fileExtension(String) - case mediaFile(TelegramMediaFile, TelegramMediaImageRepresentation) - case mediaImage(TelegramMediaImage, TelegramMediaImageRepresentation) - case audio - - static func ==(lhs: Icon, rhs: Icon) -> Bool { - switch (lhs, rhs) { - case let (.fileExtension(l), .fileExtension(r)): - return l == r - case let (.mediaFile(lFile, lRepresentation), .mediaFile(rFile, rRepresentation)): - return lFile.fileId == rFile.fileId && lRepresentation == rRepresentation - case let (.mediaImage(lImage, lRepresentation), .mediaImage(rImage, rRepresentation)): - return lImage.imageId == rImage.imageId && lRepresentation == rRepresentation - case (.audio, .audio): - return true - default: - return false - } - } -} -``` - -### Task 2: Rewrite the binding site - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 448–500). - -Before, the block started with `if case let .media(media, representation) = component.icon { ... }` then inside did `if let file = media as? TelegramMediaFile { ... } else if let image = media as? TelegramMediaImage { ... }`. - -After, use a compound case-binding pattern at the entry (both cases have the same `representation` type, so the pattern works) and an inner switch for the `setSignal` branch: - -```swift -let mediaRepresentation: TelegramMediaImageRepresentation? -switch component.icon { -case let .mediaFile(_, representation), let .mediaImage(_, representation): - mediaRepresentation = representation -default: - mediaRepresentation = nil -} - -if let representation = mediaRepresentation { - // ... setup iconImageNode as before ... - if resetImage { - switch component.icon { - case let .mediaFile(file, _): - iconImageNode.setSignal(chatWebpageSnippetFile( - account: component.context.account, - userLocation: .peer(component.messageId.peerId), - mediaReference: FileMediaReference.standalone(media: file).abstract, - representation: representation, - automaticFetch: false - )) - case let .mediaImage(image, _): - iconImageNode.setSignal(mediaGridMessagePhoto( - account: component.context.account, - userLocation: .peer(component.messageId.peerId), - photoReference: ImageMediaReference.standalone(media: image), - automaticFetch: false - )) - default: - break - } - } - // ... frame + asyncLayout + apply as before ... -} -``` - -### Task 3: Update the two construction sites - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 985 and 992). - -`imageIconValue = .media(file, representation)` → `.mediaFile(file, representation)` (for `TelegramMediaFile` branch). -`imageIconValue = .media(image, representation)` → `.mediaImage(image, representation)` (for `TelegramMediaImage` branch). - -### Task 4: Replace the placeholder `PeerId(...)` construction - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former 1062). - -The `measureItem` layout-measurement instance uses a fully-zero placeholder peer id: - -```swift -messageId: EngineMessage.Id(peerId: PeerId(namespace: PeerId.Namespace._internalFromInt32Value(0), id: PeerId.Id._internalFromInt64Value(0)), namespace: 0, id: 0), -``` - -Naming `PeerId`, `PeerId.Namespace`, `PeerId.Id` all require `import Postbox` (these are raw Postbox types, not TelegramCore typealiases). Replace with `component.context.account.peerId`, a real `EnginePeer.Id` already in scope: - -```swift -messageId: EngineMessage.Id(peerId: component.context.account.peerId, namespace: 0, id: 0), -``` - -Semantically equivalent for the measurement use case — `messageId` is used downstream only for `.peerId` extraction in the image-fetch userLocation and for Equatable comparison; the measurement instance is standalone and not compared. The `id: 0, namespace: 0` part stays; those are plain `Int32`, nothing Postbox-specific. - -Caught by second-pass build failure `cannot find 'PeerId' in scope` after dropping `import Postbox`. - -### Task 5: Drop `import Postbox` - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` (former line 14). - -Remove the `import Postbox` line. - -### Task 6: Full project build - -Expected green after Tasks 4 and 5. The first build attempt surfaced the `PeerId` issue; Task 4's fix addressed it. - -### Task 7: Commit - -Single wave-10 atomic commit. CLAUDE.md gets a wave-10 outcome section; the "Modules currently free of `import Postbox`" tally gains `StorageUsageScreen` (the module as a whole). Both files that previously imported Postbox in this module (`StorageUsageScreen.swift` from wave 9 and `StorageFileListPanelComponent.swift` from wave 10) are now Postbox-free. - ---- - -## Outcome (2026-04-20) - -Single atomic commit. Build verified green (27 actions, cached). - -**`StorageUsageScreen` consumer module is now fully Postbox-free** — last file (`StorageFileListPanelComponent.swift`) landed in this wave; the other file (`StorageUsageScreen.swift`) landed in wave 9. - -Net: 1 file changed, +22 / -29 lines (−7 simplification — the new switch-over-tuple Equatable is both terser and more idiomatic than the old three-way nested `switch` + `if case` pattern). - -**Lessons:** - -- **Heterogeneous-protocol enum cases are an easy de-Postbox win** when the protocol values already get downcast to a fixed small set of concrete subtypes. The compiler-enforced exhaustiveness of the split improves call-site safety (no silent `else` branch that forgot a subtype). -- **Placeholder `PeerId(...)` constructions in layout-measurement code are traps.** Common pattern in this codebase: a "dummy" component instance is constructed purely to run `.update(...)` and harvest the returned size. The dummy values (`messageId`, `peerId`) are not used for anything but type-filling, yet naming the types forces `import Postbox`. When de-Postboxing, look for `PeerId(namespace:...`/`MessageId(peerId:...` constructions with all-zero arguments and replace with any convenient real value already in scope (`context.account.peerId` works for peer-id placeholders). diff --git a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-7.md b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-7.md deleted file mode 100644 index b5bf1f0f57..0000000000 --- a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-7.md +++ /dev/null @@ -1,95 +0,0 @@ -# Postbox → TelegramEngine Wave 7 Implementation Plan - -> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list. - -**Goal:** Close out the remaining raw-Postbox leaks in `TelegramEngine.*` public facades surfaced by the wave-6 post-sweep scouting pass (2026-04-20). Six facade-signature migrations + one dead-facade deletion + consumer call-site bridging, landed as a single wave commit. - -**Architecture:** Wave-2 shape scaled to seven facades at once: each facade signature changes in place from raw Postbox domain types (`Message`, `Peer`) to engine equivalents (`EngineMessage`, `EnginePeer`), with `_internal_*` implementations left raw per the standing "internal Postbox-facing stays raw" rule. Consumer call sites bridge at the facade boundary via `EngineMessage.init` / `._asMessage()` wrap/unwrap helpers or drop now-redundant wrapping. - -**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build. - -**Build command:** - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError -``` - ---- - -## Scope — candidate list - -All seven items from the wave-6 post-sweep scouting pass: - -1. `TelegramEngine.Messages.downloadMessage(messageId: MessageId) -> Signal` → `(messageId: EngineMessage.Id) -> Signal`. Callers: 1 (`ChatListSearchListPaneNode`). -2. `TelegramEngine.Messages.topPeerActiveLiveLocationMessages(peerId: PeerId) -> Signal<(Peer?, [Message]), NoError>` → `(peerId: EnginePeer.Id) -> Signal<(EnginePeer?, [EngineMessage]), NoError>`. Callers: 2 (`LocationViewControllerNode`, `LiveLocationSummaryManager`). -3. `TelegramEngine.Messages.getSynchronizeAutosaveItemOperations()` — dead facade (sole caller `StoreDownloadedMedia.swift:298` uses `_internal_*` directly). Deleted. -4. `TelegramEngine.Peers.updatedRemotePeer(peer: PeerReference) -> Signal` → `Signal`. `PeerReference` param kept (no `EnginePeer.Reference` alias today). Callers: 1 (`ChannelAdminsController`, `ignoreValues` so no caller change needed). -5. `TelegramEngine.Resources.renderStorageUsageStatsMessages(…existingMessages: [EngineMessage.Id: Message]) -> Signal<[EngineMessage.Id: Message], NoError>` → `[EngineMessage.Id: EngineMessage]` on both sides. Callers: 1 (`StorageUsageScreen`). -6–8. `TelegramEngine.Resources.clearStorage(...)` overloads (three) — `[Message]` params → `[EngineMessage]`. Real external callers: 2 (`StorageUsageScreen`, two overloads). The third overload `clearStorage(messages:)` has no callers; migrated for overload-set consistency. - ---- - -## Tasks - -### Task 1: Migrate three `TelegramEngine.Messages` facades - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift` (3 facades) -- Modify: `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` (drop redundant `.flatMap(EngineMessage.init)`) -- Modify: `submodules/LocationUI/Sources/LocationViewControllerNode.swift` (drop redundant `.map(EngineMessage.init)`) -- Modify: `submodules/LiveLocationManager/Sources/LiveLocationSummaryManager.swift` (drop redundant `EnginePeer(...)` / `EngineMessage(...)` wrappers) - -**Changes:** - -`downloadMessage` — wrap return `Message?` → `EngineMessage?` via `|> map { $0.flatMap(EngineMessage.init) }`. `_internal_downloadMessage` still takes `messageId: MessageId`, which is typealiased to `EngineMessage.Id`, so the param change is purely a rename at the public surface. - -`topPeerActiveLiveLocationMessages` — wrap tuple return via `|> map { peer, messages -> (EnginePeer?, [EngineMessage]) in (peer.flatMap(EnginePeer.init), messages.map(EngineMessage.init)) }`. - -`getSynchronizeAutosaveItemOperations` — deleted. The sole caller `StoreDownloadedMedia.swift:298` was already calling `_internal_getSynchronizeAutosaveItemOperations` directly (inside its own transaction block), so no caller update needed. - -### Task 2: Migrate `TelegramEngine.Peers.updatedRemotePeer` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/TelegramEnginePeers.swift` - -Append `|> map(EnginePeer.init)` to wrap the `Peer` result. `PeerReference` param stays. Single call site in `ChannelAdminsController.swift` uses `ignoreValues`, so no caller-side change. - -### Task 3: Migrate four `TelegramEngine.Resources` facades - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift` (4 facades) -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (3 call sites) - -`renderStorageUsageStatsMessages` — unwrap `[EngineMessage.Id: EngineMessage]` input via `.mapValues { $0._asMessage() }`, wrap raw result via `.mapValues(EngineMessage.init)`. Caller bridges the other direction at its single call site (`.mapValues(EngineMessage.init)` on the input `existingMessages`, `.mapValues { $0._asMessage() }` on the mapped result). - -`clearStorage(peerId:categories:includeMessages:excludeMessages:)` / `clearStorage(peerIds:includeMessages:excludeMessages:)` / `clearStorage(messages:)` — unwrap `[EngineMessage]` params via `.map { $0._asMessage() }` before forwarding to `_internal_clearStorage`. Callers bridge `[Message]` locals with `.map(EngineMessage.init)` at the facade call site. - -Call-site changes in `StorageUsageScreen` are intentionally minimal: the file's `AggregatedData` type keeps `[MessageId: Message]` / `[Message]` internally, with bridging applied only at the four facade-call points. A full-consumer-module migration to `EngineMessage` is out of scope for this wave (would require changing ~30 sites plus the item types in `StorageFileListPanelComponent`; a future "StorageUsageScreen full de-Postbox" wave could land that). - -### Task 4: Full project build - -Run the build command above with `--continueOnError`. Expected: clean build (no errors or warnings introduced). One full build covers all facades since they're in TelegramCore and rebuilding TelegramCore re-verifies every consumer. - -### Task 5: Commit - -Single wave-7 atomic commit covering the 8 modified files and the CLAUDE.md outcome update. - ---- - -## Outcome (2026-04-20) - -All seven candidates landed. Single atomic commit. Build verified green (`bazel-bin/Telegram/Telegram.ipa` produced; 5854 total actions, 1009 executed). - -- 3 `TelegramEngine.Messages` facades migrated (1 rewrite, 1 rewrite, 1 deletion) -- 1 `TelegramEngine.Peers` facade migrated -- 4 `TelegramEngine.Resources` facades migrated (1 dict, 3 overloads) -- 5 consumer files updated: `ChatListSearchListPaneNode`, `LocationViewControllerNode`, `LiveLocationSummaryManager`, `StorageUsageScreen`, CLAUDE.md - -No modules became Postbox-free in this wave (all five touched consumers still import Postbox for unrelated reasons — `StorageUsageScreen` especially, which still has 43 raw `Message` / `MessageId` references outside the facade boundary). - -**Lesson recorded:** when a facade's consumer file uses the raw Postbox type extensively outside the facade boundary (e.g. `StorageUsageScreen` with its `[MessageId: Message]` dict stored in a helper class and threaded through ~30 sites), bridging at the facade call site is the correct scope. Full-consumer-module migration is its own separate wave, not a side-effect of facade migration. - -**Next-wave candidates.** The sum of the scouting pass's 8 candidates has been closed. No new `TelegramEngine.*` public facades with raw `Postbox`/`Account`/`MediaBox`/`Peer`/`Message`/`MediaResource` leaks remain. Future-wave focus shifts to: - -1. Full-consumer-module migrations (e.g. `StorageUsageScreen` — drop `AggregatedData`'s raw-Postbox storage types, drop `import Postbox`). -2. Another speculative unused-import sweep pass like wave 6, to catch imports that became unused after waves 4–7. diff --git a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md deleted file mode 100644 index 85e3edd146..0000000000 --- a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md +++ /dev/null @@ -1,103 +0,0 @@ -# Postbox → TelegramEngine Wave 8 Implementation Plan - -> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list. - -**Goal:** `StorageUsageScreen` consumer-module migration — drop all raw `Message` domain types from the screen's internal storage and public peer-panel item types, and eliminate the wave-7 facade-boundary bridging. Scope is narrower than a full de-Postbox of the module: direct `postbox.combinedView` / `postbox.transaction` sites for `AccountSpecificCacheStorageSettings` observation are left for a future wave. - -**Architecture:** Two files modified. `StorageFileListPanelComponent.Item.message` and `StorageUsageScreen`'s `AggregatedData` + `RenderResult` + `SelectionState` internal types are migrated from raw `Message`/`[Message]`/`[MessageId: Message]` to `EngineMessage`/`[EngineMessage]`/`[EngineMessage.Id: EngineMessage]`. The two external APIs that still take raw `Message` (`OpenChatMessageParams.message`, `chatMediaListPreviewControllerData(message:)`) are called with `engineMessage._asMessage()` at the call site. - -**Tech Stack:** Swift / Bazel. No unit tests by repo policy — verification is a full project build. - -**Build command:** - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError -``` - ---- - -## Scope - -**In scope:** - -- `StorageFileListPanelComponent.Item.message: Message` → `EngineMessage` (the item type co-located with the panel component). -- `StorageUsageScreen.Component.SelectionState.togglePeer(id:availableMessages: [EngineMessage.Id: Message])` → `[EngineMessage.Id: EngineMessage]`. -- `StorageUsageScreen.Component.AggregatedData.messages: [MessageId: Message]` → `[EngineMessage.Id: EngineMessage]`. -- `AggregatedData.clearIncludeMessages: [Message]` / `.clearExcludeMessages: [Message]` → `[EngineMessage]` (plus the corresponding local vars in `AggregatedData.updateSelected...`). -- `AggregatedData.init(..., messages: [MessageId: Message])` → `[EngineMessage.Id: EngineMessage]`. -- `StorageUsageScreen.Component.RenderResult.messages: [MessageId: Message]` → `[EngineMessage.Id: EngineMessage]`. -- `openMessage(message: Message)` → `openMessage(message: EngineMessage)`. -- Drop the now-redundant wave-7 facade-boundary bridging (`.mapValues(EngineMessage.init)` on `existingMessages`, `.mapValues { $0._asMessage() }` on the facade's engineMessages output, `.map(EngineMessage.init)` on the two `clearStorage` call sites, `._asMessage()` on `item.message` inside the `AggregatedData.updateSelected...` loop, and `EngineMessage(message)` inside the `result.imageItems.append(...)` site). - -**Out of scope — left for a future wave:** - -- Direct postbox usage for `AccountSpecificCacheStorageSettings` observation: `StorageUsageScreen.swift:1047-1062` and `3131-3185`. Blocks `import Postbox` removal. Requires engine equivalents for `PostboxViewKey.preferences` / `PreferencesView` observation and for `transaction.getPeer` / `transaction.getPeerCachedData` — likely an `EngineData`-subscription based rewrite plus peer-category classification via already-existing engine APIs. -- `StorageFileListPanelComponent`'s `Icon.media(Media, TelegramMediaImageRepresentation)` enum case. Holds either `TelegramMediaFile` or `TelegramMediaImage` (always one of these two TelegramCore types per `imageIconValue = .media(file, ...)` and `.media(image, ...)` construction sites). Could be split into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)` to eliminate the raw `Media` protocol dependency; out of scope as it's unrelated to Message-type migration. - ---- - -## Tasks - -### Task 1: Migrate `StorageFileListPanelComponent.Item.message` - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` — `Item.message` type and `init(message:)` param. - -No other changes inside the file. Internal usage (`item.message.id`, `item.message.timestamp`, `item.message.media`) already works on `EngineMessage` — `EngineMessage.media` returns `[Media]` (raw), so the `as? TelegramMediaFile` / `as? TelegramMediaImage` downcasts inside the `for media in item.message.media` loop still compile. - -### Task 2: Migrate `StorageUsageScreen` internal storage types - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` - -Change: -- `SelectionState.togglePeer(availableMessages: [EngineMessage.Id: Message])` → `[EngineMessage.Id: EngineMessage]`. Body only uses `messageId.peerId` so no body change required. -- `AggregatedData.messages` / `.clearIncludeMessages` / `.clearExcludeMessages` type declarations and the init param. -- The selected-messages accumulation loop inside `AggregatedData` (the block running from the photo/video/file/music category branches): drop `item.message._asMessage()` in the two photo/video branches (`imageItems` holds EngineMessage items, so `._asMessage()` was the EngineMessage→Message unwrap to fit the old `[Message]` local); `item.message` in the file/music branches now passes through since Item.message is EngineMessage. -- `RenderResult.messages` type. - -### Task 3: Drop wave-7 facade-boundary bridging - -At `StorageUsageScreen.swift:2397` the `renderStorageUsageStatsMessages` call previously wrapped input via `(self.aggregatedData?.messages ?? [:]).mapValues(EngineMessage.init)` and unwrapped output via `.mapValues { $0._asMessage() }`. With `AggregatedData.messages` and `RenderResult.messages` now EngineMessage-typed, both bridges vanish: the call just passes `self.aggregatedData?.messages ?? [:]` directly and assigns the result to `result.messages` unchanged. - -At the two `clearStorage` call sites in `StorageUsageScreen.swift` (inside `clearSelected(...)`): `aggregatedData.clearIncludeMessages.map(EngineMessage.init)` → `aggregatedData.clearIncludeMessages` (same for `excludeMessages`), plus the local `includeMessages: [Message]` / `excludeMessages: [Message]` vars become `[EngineMessage]`. - -At the `RenderResult`-building loop (post-`renderStorageUsageStatsMessages`), `StorageMediaGridPanelComponent.Item(message: EngineMessage(message), ...)` → `message: message` since `message` is already `EngineMessage`. - -### Task 4: Migrate `openMessage` + external-API unwraps - -`openMessage(message: Message)` → `openMessage(message: EngineMessage)`. Two external APIs receive raw `Message`: pass `message._asMessage()` to `OpenChatMessageParams(message:)` inside `openMessage`, and to `chatMediaListPreviewControllerData(message:)` inside `messageGaleryContextAction`. Also drop the one-line `let foundGalleryMessage: Message? = message` + `guard let galleryMessage = foundGalleryMessage` dance inside `openMessage` — it's a no-op wrap preserved from an older version. - -### Task 5: Full project build - -Expected clean (cached — 30 seconds on an incremental build; ~60s from a cold start since wave 7). - -### Task 6: Commit - -Single wave-8 atomic commit. - ---- - -## Outcome (2026-04-20) - -Single atomic commit landing the migration. Build verified green (59s incremental, 27 actions). Net -5 lines (simplification, as expected — most changes are type swaps and a handful of redundant wraps/unwraps removed). - -Two files modified: - -| File | Δ | -|---|---| -| `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` | +33 / -44 | -| `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageFileListPanelComponent.swift` | +3 / -3 | - -**Module did NOT become Postbox-free.** Both files retain `import Postbox` for the out-of-scope sites listed above. Drop-candidacy inventory in `StorageUsageScreen.swift`: - -- 1047–1062: preferences-view observation of `AccountSpecificCacheStorageSettings` via `postbox.combinedView` + `PreferencesView`. -- 3131–3185: second preferences-view observation + `postbox.transaction { transaction in ... transaction.getPeer / transaction.getPeerCachedData as? CachedGroupData / CachedChannelData ... }` for classifying peer-storage-timeout exceptions. - -And in `StorageFileListPanelComponent.swift`: - -- 105: `Icon.media(Media, TelegramMediaImageRepresentation)` enum case. - -Future wave targets either the preferences-view observation sites (substantial — `EngineData`-subscription rewrite + peer-category classification via engine APIs) or the `Icon.media` split (trivial — 3 sites to update). - -Plan / record: `docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-8.md`. diff --git a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md b/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md deleted file mode 100644 index 28e5c85c9a..0000000000 --- a/docs/superpowers/plans/2026-04-20-postbox-to-telegramengine-wave-9.md +++ /dev/null @@ -1,162 +0,0 @@ -# Postbox → TelegramEngine Wave 9 Implementation Plan - -> **For agentic workers:** This plan was executed in a single session; steps below are a post-hoc record of the work landed, not a to-do list. - -**Goal:** Finish the `StorageUsageScreen` de-Postbox work started in wave 8 by rewriting the two remaining direct-postbox sites that observe `AccountSpecificCacheStorageSettings`, and drop `import Postbox` from `StorageUsageScreen.swift`. - -**Architecture:** Replace `postbox.combinedView(keys: [.preferences(...)]) + PreferencesView` observation with `context.engine.data.subscribe(TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key:))`, which returns `PreferencesEntry?` and is then decoded the same way (`.get(AccountSpecificCacheStorageSettings.self)`). Replace the transaction-based per-peer classification (`transaction.getPeer` + `transaction.getPeerCachedData as? CachedGroupData/CachedChannelData`) with an `EngineDataMap` of `TelegramEngine.EngineData.Item.Peer.Peer.init(id:)` lookups producing `EnginePeer?` values that pattern-match on `.user` / `.legacyGroup` / `.channel(channel)` / `.secretChat`. The `FoundPeer(peer:subscribers:)` wrapper in the signal's element type is dropped entirely since downstream consumers (`peerExceptions.isEmpty`, `.count`, `.prefix(3).map { EnginePeer($0.peer.peer) }`) never read `subscribers`. - -**Tech Stack:** Swift / Bazel. No unit tests. - -**Build command:** - -```bash -source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 --continueOnError -``` - ---- - -## Scope - -Two direct-postbox site clusters rewritten in `StorageUsageScreen.swift`: - -1. **Site 1 (former lines 1047–1087)** — `cacheSettingsExceptionCount` signal. Preserved its downstream `EngineDataMap` + `EnginePeer` per-category counting logic unchanged; only the preferences observation replaced. -2. **Site 2 (former lines 3131–3196)** — `peerExceptions` signal inside `openKeepMediaCategory`. Both the preferences observation AND the `postbox.transaction { transaction.getPeer / transaction.getPeerCachedData ... FoundPeer(...) }` block replaced. Signal element type changed from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`; `FoundPeer` and the unread `subscribers` field dropped. - -One consumer-side edit: `peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }` (at the `MultiplePeerAvatarsContextItem` construction). - -One typealias fixup: `var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]` (required once `import Postbox` is removed, since `MessageId` is the raw Postbox name, not a TelegramCore typealias). - -`import Postbox` removed from `StorageUsageScreen.swift`. - ---- - -## Tasks - -### Task 1: Rewrite site 1 — cacheSettingsExceptionCount - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 1047–1058). - -Replace the preferences observation header. The downstream `mapToSignal { ... EngineDataMap ... EnginePeer ... }` body is already Engine-only and unchanged. - -Before: - -```swift -let viewKey: PostboxViewKey = .preferences(keys: Set([PreferencesKeys.accountSpecificCacheStorageSettings])) -let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = component.context.account.postbox.combinedView(keys: [viewKey]) -|> map { views -> AccountSpecificCacheStorageSettings in - let cacheSettings: AccountSpecificCacheStorageSettings - if let view = views.views[viewKey] as? PreferencesView, let value = view.values[PreferencesKeys.accountSpecificCacheStorageSettings]?.get(AccountSpecificCacheStorageSettings.self) { - cacheSettings = value - } else { - cacheSettings = AccountSpecificCacheStorageSettings.defaultSettings - } - return cacheSettings -} -|> distinctUntilChanged -|> mapToSignal { ... } -``` - -After: - -```swift -let cacheSettingsExceptionCount: Signal<[CacheStorageSettings.PeerStorageCategory: Int32], NoError> = context.engine.data.subscribe( - TelegramEngine.EngineData.Item.Configuration.ApplicationSpecificPreference(key: PreferencesKeys.accountSpecificCacheStorageSettings) -) -|> map { preferencesEntry -> AccountSpecificCacheStorageSettings in - return preferencesEntry?.get(AccountSpecificCacheStorageSettings.self) ?? AccountSpecificCacheStorageSettings.defaultSettings -} -|> distinctUntilChanged -|> mapToSignal { ... } -``` - -### Task 2: Rewrite site 2 — peerExceptions - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3131–3196). - -Replace both the preferences observation (as in Task 1) AND the subsequent `mapToSignal { context.account.postbox.transaction { ... } }` block. Signal element type changes from `[(peer: FoundPeer, value: Int32)]` to `[(peer: EnginePeer, value: Int32)]`. `subscriberCount` is not preserved — it's computed but never read by downstream consumers. - -After (showing the `peerExceptions` signal in full): - -```swift -let peerExceptions: Signal<[(peer: EnginePeer, value: Int32)], NoError> = accountSpecificSettings -|> mapToSignal { accountSpecificSettings -> Signal<[(peer: EnginePeer, value: Int32)], NoError> in - return context.engine.data.get( - EngineDataMap(accountSpecificSettings.peerStorageTimeoutExceptions.map(\.key).map(TelegramEngine.EngineData.Item.Peer.Peer.init(id:))) - ) - |> map { peers -> [(peer: EnginePeer, value: Int32)] in - var result: [(peer: EnginePeer, value: Int32)] = [] - for item in accountSpecificSettings.peerStorageTimeoutExceptions { - guard let peer = peers[item.key] ?? nil else { continue } - let peerCategory: CacheStorageSettings.PeerStorageCategory - switch peer { - case .user, .secretChat: - peerCategory = .privateChats - case .legacyGroup: - peerCategory = .groups - case let .channel(channel): - if case .group = channel.info { - peerCategory = .groups - } else { - peerCategory = .channels - } - } - if peerCategory != mappedCategory { continue } - result.append((peer: peer, value: item.value)) - } - return result.sorted(by: { lhs, rhs in - if lhs.value != rhs.value { - return lhs.value < rhs.value - } - return lhs.peer.debugDisplayTitle < rhs.peer.debugDisplayTitle - }) - } -} -``` - -### Task 3: Update consumer of `peerExceptions` - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 3288). - -`peerExceptions.prefix(3).map { EnginePeer($0.peer.peer) }` → `peerExceptions.prefix(3).map { $0.peer }`. The `MultiplePeerAvatarsContextItem(context:, peers: [EnginePeer], totalCount:, action:)` signature is unchanged — we simply drop the redundant `EnginePeer(...)` wrap because `$0.peer` is now already an `EnginePeer`. - -### Task 4: Drop `import Postbox` - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (line 12). - -Remove the `import Postbox` line. - -### Task 5: Typealias fixup for `MessageId` - -**Files:** -- Modify: `submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageUsageScreen.swift` (former 2397). - -`var mergedMedia: [MessageId: Int64]` → `[EngineMessage.Id: Int64]`. `MessageId` is the raw Postbox type name; with `import Postbox` removed, the type must be named through the `EngineMessage.Id` typealias. Discovered by first-pass build failure `cannot find type 'MessageId' in scope`. - -### Task 6: Full project build - -Expected green. Incremental build: ~60s (cached), 27 actions. - -### Task 7: Commit - -Single wave-9 atomic commit. CLAUDE.md updates the wave 8 outcome's "future-wave candidates" note since this wave closes both of them. `StorageUsageScreen` (the module as a whole) now has `StorageUsageScreen.swift` Postbox-free; the module's `StorageFileListPanelComponent.swift` still imports Postbox because of the `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (trivial future wave, as previously noted). - ---- - -## Outcome (2026-04-20) - -Single atomic commit. Build verified green (27 actions, ~60s incremental). - -Net change: 1 file, +30 / -54 lines (-24 simplification). - -Lessons: - -- **`ApplicationSpecificPreference(key:)` is the general-purpose engine replacement** for any `postbox.combinedView(keys: [.preferences(keys: Set([key]))])` idiom. Takes a `ValueBoxKey`, returns `PreferencesEntry?`, decodes via `.get(T.self)`. Usable from any module that imports `TelegramCore` even without `import Postbox`, because the `ValueBoxKey`-typed input is obtained through a statically-named `PreferencesKeys.*` member (no `ValueBoxKey` identifier appears in the consumer). -- **`MessageId` is raw Postbox, not a TelegramCore typealias.** CLAUDE.md's "engine typealias cheat sheet" labels `PeerId`, `MessageId`, etc. as migration *targets*, not existing aliases. Files that drop `import Postbox` must rename these to `EngineMessage.Id` / `EnginePeer.Id`. Caught by the first-pass build failure. -- **Dead-code detection during rewrites.** The transaction block's `subscriberCount` computation and the `FoundPeer.subscribers` field it populated were never consumed downstream. The rewrite simply dropped them, shrinking the code further than a 1:1 rewrite would have. - -`StorageUsageScreen.swift` is now Postbox-free. The `StorageUsageScreen` consumer module as a whole is still not fully Postbox-free because `StorageFileListPanelComponent.swift` retains `import Postbox` for its `Icon.media(Media, TelegramMediaImageRepresentation)` enum case (3 construction sites; trivial future wave splits into `.mediaFile(TelegramMediaFile, ...)` / `.mediaImage(TelegramMediaImage, ...)`). diff --git a/docs/superpowers/plans/2026-04-21-swifttl-layered-schema-generation.md b/docs/superpowers/plans/2026-04-21-swifttl-layered-schema-generation.md deleted file mode 100644 index c5f5bc9549..0000000000 --- a/docs/superpowers/plans/2026-04-21-swifttl-layered-schema-generation.md +++ /dev/null @@ -1,1037 +0,0 @@ -# SwiftTL — Layered Schema Generation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extend `build-system/SwiftTL` to parse `.tl` schemas containing `===N===` layer markers and emit one cumulative-snapshot `{apiPrefix}Layer{N}.swift` file per layer, while leaving the flat schema pipeline byte-identical. - -**Architecture:** Approach 1 from the design spec (`docs/superpowers/specs/2026-04-21-swifttl-layered-schema-generation-design.md`). `DescriptionParser` returns a new `ParsedSchema` enum (`.flat` or `.layered`). `Resolver` gains `resolveLayeredTypes(layers:)` that snapshots a running constructor map per layer. `CodeGenerator` gains `generateLayered(apiPrefix:, layerNumber:, types:)` that emits a single-file-per-layer shape matching the existing hand-written `SecretApiLayer{N}.swift`. `main.swift` branches on `ParsedSchema`. - -**Tech Stack:** Swift 5.5 executable target (`build-system/SwiftTL/Package.swift`). Vendored `Parser` combinator library for parsing. Project itself uses Bazel (`Make.py build`) for the full iOS build — `swift build` and `swift run` work at the package level for SwiftTL itself. - -**Testing note:** per `CLAUDE.md`, no unit tests exist in this project. Verification is end-to-end: `swift run SwiftTL` on real schema files, diff against existing hand-written output, full Bazel build of the iOS app. No TDD steps in this plan — each task's verification is either (a) `swift build` (compiles), (b) `swift run SwiftTL` producing expected files, or (c) full Bazel build succeeds. - -**Baseline:** this plan is written against a pre-existing prep commit landed just before Task 1 begins, which threads `apiPrefix: String` through `CodeGenerator.generate` / `generateMainFile` / `generateImplFile` / `typeReferenceRepresentation`, removes dead `--stub-functions` and `--print-constructors` CLI flags from `main.swift`, and deletes the unused `LegacyOrderParser.swift`. All task-level edits below assume these changes are already committed — code snippets and line numbers reference the post-prep-commit state of the files. - ---- - -## File Structure - -All edits within `build-system/SwiftTL/Sources/SwiftTL/`: - -- `DescriptionParsing.swift` — add `ParsedSchema` enum, rework `parse(data:)` to detect `===N===` markers and route lines per layer. -- `Resolution.swift` — add `resolveLayeredTypes(layers:)` that walks sections and snapshots a running map. -- `CodeGeneration.swift` — add `generateLayered(apiPrefix:, layerNumber:, types:)` that emits one `{apiPrefix}Layer{N}.swift` string. -- `main.swift` — branch on `ParsedSchema`, loop for layered, unchanged for flat. - -Downstream changes in the repo: - -- `submodules/TelegramApi/Sources/SecretApiLayer{8,17,20,23,45,46,66,73,101,143,144}.swift` — replace existing 5 (of 11) with generator output; 6 new files added. BUILD uses `glob("Sources/**/*.swift")` so no BUILD update needed (confirmed — `submodules/TelegramApi/BUILD` line 9). - -Reference artifacts to consult during implementation: - -- Input schema: `/Users/isaac/build/telegram/telegram-ios-shared/tools/secret_scheme.tl` (112 lines). -- Legacy per-layer output to match: `submodules/TelegramApi/Sources/SecretApiLayer{8,46,73,101,144}.swift`. -- Flat reference: `submodules/TelegramApi/Sources/Api0.swift` and sharded `Api{1..5}.swift` — shows the `useStructPattern = true` shape to contrast against. -- Invocation script (not to be edited in this plan — spec calls it out as a follow-up in the sibling repo): `/Users/isaac/build/telegram/telegram-ios-shared/tools/generate_and_copy_scheme.sh`. - ---- - -### Task 1: `ParsedSchema` type + layered parsing in `DescriptionParser` - -**Files:** -- Modify: `build-system/SwiftTL/Sources/SwiftTL/DescriptionParsing.swift` - -**Goal:** Replace `DescriptionParser.parse(data:) -> (constructors, functions)` with `parse(data:) -> ParsedSchema`, where `ParsedSchema` is a new enum with `.flat` and `.layered` cases. Layered mode triggers on any line matching `^===\d+===\s*$`. Flat mode is byte-identical to today. - -- [ ] **Step 1: Add the `ParsedSchema` enum at the top of `DescriptionParser`** - -Insert immediately after the `enum DescriptionParser {` opening brace, before `enum TypeReferenceDescription`: - -```swift -enum ParsedSchema { - case flat(constructors: [ConstructorDescription], functions: [ConstructorDescription]) - case layered(layers: [(layerNumber: Int, constructors: [ConstructorDescription])]) -} - -struct SchemaParsingError: Error, CustomStringConvertible { - var text: String - var description: String { text } -} -``` - -- [ ] **Step 2: Rewrite `parse(data:)` signature and dispatch logic** - -Replace the current `static func parse(data: String) throws -> (constructors: [ConstructorDescription], functions: [ConstructorDescription])` (currently at lines 27–99) with: - -```swift -static func parse(data: String) throws -> ParsedSchema { - let lines = data.components(separatedBy: "\n") - - // Detect layered mode: any line of the form ===N=== - let layerMarker = try NSRegularExpression(pattern: "^===\\d+===\\s*$") - let hasLayerMarker = lines.contains { line in - let range = NSRange(line.startIndex..., in: line) - return layerMarker.firstMatch(in: line, range: range) != nil - } - - if hasLayerMarker { - return try parseLayered(lines: lines) - } else { - return try parseFlat(lines: lines) - } -} -``` - -- [ ] **Step 3: Extract the existing flat-parsing body into `parseFlat(lines:)`** - -Add directly below `parse(data:)`. This is the existing logic verbatim, just accepting pre-split lines and returning the new enum case: - -```swift -private static func parseFlat(lines: [String]) throws -> ParsedSchema { - var typeLines: [String] = [] - var functionLines: [String] = [] - - let skipPrefixes: [String] = [ - "true#3fedd339 = True;", - "vector#1cb5c415 {t:Type} # [ t ] = Vector t;", - "error#c4b9f9bb code:int text:string = Error;", - "null#56730bcc = Null;" - ] - let skipContains: [String] = ["{X:Type}"] - - var isParsingFunctions = false - loop: for line in lines { - if line.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { - continue - } else if line == "---functions---" { - isParsingFunctions = true - } else { - for string in skipPrefixes { - if line.hasPrefix(string) { continue loop } - } - for string in skipContains { - if line.contains(string) { continue loop } - } - if isParsingFunctions { - functionLines.append(line) - } else { - typeLines.append(line) - } - } - } - - var constructors: [ConstructorDescription] = [] - var functions: [ConstructorDescription] = [] - - for line in typeLines { - do { - constructors.append(try parseConstructor(string: line)) - } catch let e { - print("Error while parsing line:\n\(line)\n") - print("\(e)") - throw e - } - } - for line in functionLines { - do { - functions.append(try parseConstructor(string: line)) - } catch let e { - print("Error while parsing line:\n\(line)\n") - print("\(e)") - throw e - } - } - - return .flat(constructors: constructors, functions: functions) -} -``` - -- [ ] **Step 4: Add `parseLayered(lines:)`** - -Add directly below `parseFlat`: - -```swift -private static func parseLayered(lines: [String]) throws -> ParsedSchema { - let skipPrefixes: [String] = [ - "true#3fedd339 = True;", - "vector#1cb5c415 {t:Type} # [ t ] = Vector t;", - "error#c4b9f9bb code:int text:string = Error;", - "null#56730bcc = Null;" - ] - let skipContains: [String] = ["{X:Type}"] - let layerMarker = try NSRegularExpression(pattern: "^===(\\d+)===\\s*$") - - // Pre-marker constructor lines accumulate here and are attached to the first declared layer. - var preMarkerLines: [String] = [] - var sections: [(layerNumber: Int, lines: [String])] = [] - var lastLayerNumber: Int? = nil - - loop: for line in lines { - let trimmed = line.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - if trimmed.isEmpty { continue } - - if line == "---functions---" { - throw SchemaParsingError(text: "Layered schemas may not declare ---functions---; secret/layered schemas are types-only.") - } - - let range = NSRange(line.startIndex..., in: line) - if let match = layerMarker.firstMatch(in: line, range: range), - let numberRange = Range(match.range(at: 1), in: line), - let layerNumber = Int(line[numberRange]) - { - if let previous = lastLayerNumber, layerNumber <= previous { - throw SchemaParsingError(text: "Layer markers must appear in strictly ascending order; found ===\(layerNumber)=== after ===\(previous)===.") - } - sections.append((layerNumber, [])) - lastLayerNumber = layerNumber - continue - } - - // Apply the same skip rules as flat mode. - for string in skipPrefixes { - if line.hasPrefix(string) { continue loop } - } - for string in skipContains { - if line.contains(string) { continue loop } - } - - if sections.isEmpty { - preMarkerLines.append(line) - } else { - sections[sections.count - 1].lines.append(line) - } - } - - if sections.isEmpty { - throw SchemaParsingError(text: "Layered schema has a layer marker regex match but no ===N=== sections were extracted; this indicates a parser bug.") - } - - // Attach pre-marker lines to the first (lowest) declared layer. - if !preMarkerLines.isEmpty { - sections[0].lines.insert(contentsOf: preMarkerLines, at: 0) - } - - var layers: [(layerNumber: Int, constructors: [ConstructorDescription])] = [] - for (layerNumber, sectionLines) in sections { - var constructors: [ConstructorDescription] = [] - for line in sectionLines { - do { - constructors.append(try parseConstructor(string: line)) - } catch let e { - print("Error while parsing line (layer \(layerNumber)):\n\(line)\n") - print("\(e)") - throw e - } - } - layers.append((layerNumber, constructors)) - } - - return .layered(layers: layers) -} -``` - -- [ ] **Step 5: Verify SwiftTL compiles** - -Run: `cd build-system/SwiftTL && swift build 2>&1` -Expected: build succeeds OR fails only at call sites in `main.swift` (the next task fixes main.swift). Errors inside `DescriptionParsing.swift` mean the rewrite has a syntax/type issue — fix before proceeding. - -Note: at this point `main.swift` still calls `parse(data:)` expecting the old tuple return type, so `swift build` from the package root will fail at the main-file callsite with a type-mismatch error. That's expected; Task 4 fixes it. - -- [ ] **Step 6: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/SwiftTL/Sources/SwiftTL/DescriptionParsing.swift -git commit -m "$(cat <<'EOF' -SwiftTL: add ParsedSchema + layered schema parsing - -DescriptionParser.parse(data:) now returns ParsedSchema (.flat or -.layered) based on the presence of ===N=== markers. Layered schemas -split constructor lines per layer; pre-marker constructors attach to -the lowest-numbered layer; ---functions--- is rejected in layered -mode; non-ascending markers throw. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 2: `Resolver.resolveLayeredTypes` for per-layer cumulative snapshots - -**Files:** -- Modify: `build-system/SwiftTL/Sources/SwiftTL/Resolution.swift` - -**Goal:** Add `Resolver.resolveLayeredTypes(layers:)` that walks per-layer constructor descriptions in order, threads a running name→constructor map with last-wins semantics, and snapshots `[SumType]` at the end of each layer. Shares argument-resolution logic with the existing `resolveTypes(constructors:)` (by factoring a shared helper closure/method). - -- [ ] **Step 1: Add `resolveLayeredTypes` to the `Resolver` enum** - -Insert this method immediately after `resolveTypes(constructors:)` (which ends at Resolution.swift:201, `return types.values.sorted(by: { $0.name < $1.name })`). The method threads mutable state (a running map of constructor name → resolved SumType.Constructor and target type) and snapshots at each layer boundary: - -```swift -static func resolveLayeredTypes( - layers: [(layerNumber: Int, constructors: [DescriptionParser.ConstructorDescription])] -) throws -> [(layerNumber: Int, types: [SumType])] { - // Running state: for each constructor name, the target type name and the raw description. - // We keep raw descriptions (not resolved forms) because a later-layer constructor may - // introduce new target-type names, and resolveTypeReference needs the final target-type set. - var liveConstructors: [QualifiedName: (typeName: QualifiedName, description: DescriptionParser.ConstructorDescription)] = [:] - var result: [(layerNumber: Int, types: [SumType])] = [] - - for (layerNumber, layerConstructors) in layers { - // Apply this layer's constructors to the running map with last-wins semantics. - for constructorDescription in layerConstructors { - switch constructorDescription.type { - case let .type(name): - if !name.value[name.value.startIndex].isUppercase { - throw ResolutionError(text: "Type constructor \(constructorDescription.name) -> \(name): the resulting type name should begin with a capital letter") - } - liveConstructors[constructorDescription.name] = (name, constructorDescription) - case let .generic(name, argumentType): - throw ResolutionError(text: "Type constructor \(constructorDescription.name) can not be used to construct a generic type \(name)<\(argumentType)>") - } - } - - // Snapshot: group by target type, resolve. - var constructedTypes: [QualifiedName: [DescriptionParser.ConstructorDescription]] = [:] - var constructorNameToType: [QualifiedName: QualifiedName] = [:] - for (ctorName, entry) in liveConstructors { - constructedTypes[entry.typeName, default: []].append(entry.description) - constructorNameToType[ctorName] = entry.typeName - } - - func resolveTypeReference(description: DescriptionParser.TypeReferenceDescription) throws -> TypeReference { - switch description { - case let .type(name): - if let resolvedBuiltinType = resolveBuiltinType(name: name) { - return resolvedBuiltinType - } - if name.value[name.value.startIndex].isUppercase { - if let _ = constructedTypes[name] { - return .boxedType(name) - } else { - throw ResolutionError(text: "Unresolved type \(name) in layer \(layerNumber)") - } - } else { - if let typeName = constructorNameToType[name] { - return .bareConstructor(typeName: typeName, name: name) - } else { - throw ResolutionError(text: "Unresolved type constructor \(name) in layer \(layerNumber)") - } - } - case let .generic(name, argumentType): - if name == "vector" { - return .bareVector(try resolveTypeReference(description: .type(name: argumentType))) - } else if name == "Vector" { - return .boxedVector(try resolveTypeReference(description: .type(name: argumentType))) - } else { - throw ResolutionError(text: "Unresolved generic type \(name) in layer \(layerNumber)") - } - } - } - - func resolveArgument(existingArguments: [Argument], description: DescriptionParser.ArgumentDescription) throws -> Argument { - return Argument( - name: description.name, - type: try resolveTypeReference(description: description.type), - condition: try description.condition.flatMap { condition -> Argument.Condition in - if !existingArguments.contains(where: { $0.name == condition.fieldName }) { - throw ResolutionError(text: "Unresolved conditional field reference to \(condition.fieldName) in layer \(layerNumber)") - } - return Argument.Condition(fieldName: condition.fieldName, bitIndex: condition.bitIndex) - } - ) - } - - var types: [QualifiedName: SumType] = [:] - for (typeName, constructorDescriptions) in constructedTypes { - let type = SumType(name: typeName) - for constructorDescription in constructorDescriptions { - var arguments: [Argument] = [] - for argumentDescription in constructorDescription.arguments { - arguments.append(try resolveArgument(existingArguments: arguments, description: argumentDescription)) - } - guard let id = constructorDescription.explicitId else { - throw ResolutionError(text: "Constructor \(constructorDescription.name) does not have an id") - } - type.constructors[constructorDescription.name] = SumType.Constructor( - name: constructorDescription.name, - id: id, - arguments: arguments - ) - } - types[type.name] = type - } - - let sortedTypes = types.values.sorted(by: { $0.name < $1.name }) - result.append((layerNumber, sortedTypes)) - } - - return result -} -``` - -- [ ] **Step 2: Verify SwiftTL package compiles (DescriptionParsing + Resolution)** - -Run: `cd build-system/SwiftTL && swift build 2>&1 | head -50` -Expected: the only remaining errors are in `main.swift` (unchanged in this task). If `Resolution.swift` itself has errors, fix before proceeding. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/SwiftTL/Sources/SwiftTL/Resolution.swift -git commit -m "$(cat <<'EOF' -SwiftTL: add Resolver.resolveLayeredTypes - -Walks layer sections in order, threads a running constructor-name map -with last-wins semantics, and snapshots [SumType] at each layer -boundary. Constructors appearing only in later layers do not leak into -earlier layers' snapshots. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 3: `CodeGenerator.generateLayered` — one file per layer - -**Files:** -- Modify: `build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift` - -**Goal:** Emit one `{apiPrefix}Layer{N}.swift` file per layer, matching the shape of the existing hand-written `SecretApiLayer8.swift` (nested `public struct`, inline enum case args, `fileprivate parse_*`). Reuses `typeReferenceRepresentation`, `generateFieldSerialization`, `generateFieldParsing`, and `SumType.hasDirectReference(to:typeMap:)` unchanged. - -Reference for the expected output shape: read `submodules/TelegramApi/Sources/SecretApiLayer8.swift` (the first 80 lines show the header + struct body; lines ~85+ show the nested enums). The generator output will not byte-match but must match this *shape*. - -- [ ] **Step 1: Add `generateLayered` entry point to `CodeGenerator`** - -Insert the following method after the existing `generate(...)` method (which ends at CodeGeneration.swift:200). Reads directly; uses the same `CodeWriter` helper and private type helpers already in the file: - -```swift -static func generateLayered( - apiPrefix: String, - layerNumber: Int, - types: [Resolver.SumType] -) throws -> (filename: String, source: String) { - let structName = "\(apiPrefix)\(layerNumber)" - let filename = "\(apiPrefix)Layer\(layerNumber).swift" - - var typeMap: [QualifiedName: Resolver.SumType] = [:] - for type in types { - typeMap[type.name] = type - } - - // Detect whether any constructor argument uses Int256; if so, we need the int256 parser entry. - var usesInt256 = false - for type in types { - for (_, constructor) in type.constructors { - for argument in constructor.arguments { - if containsInt256(argument.type) { usesInt256 = true; break } - } - if usesInt256 { break } - } - if usesInt256 { break } - } - - var writer = CodeWriter() - writer.line() - - // File-scope dispatch table - writer.line("fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = {") - writer.indent() - writer.line("var dict: [Int32 : (BufferReader) -> Any?] = [:]") - writer.line("dict[-1471112230] = { return $0.readInt32() }") - writer.line("dict[570911930] = { return $0.readInt64() }") - writer.line("dict[571523412] = { return $0.readDouble() }") - writer.line("dict[-1255641564] = { return parseString($0) }") - if usesInt256 { - writer.line("dict[0x0929C32F] = { return parseInt256($0) }") - } - - let sortedTypes = types.sorted(by: { $0.name < $1.name }) - for type in sortedTypes { - let sortedConstructors = type.constructors.values.sorted(by: { $0.name < $1.name }) - for constructor in sortedConstructors { - writer.line("dict[\(Int32(bitPattern: constructor.id))] = { return \(structName).\(type.name).parse_\(constructor.name.value)($0) }") - } - } - writer.line("return dict") - writer.dedent() - writer.line("}()") - writer.line() - - // public struct {apiPrefix}{N} { - writer.line("public struct \(structName) {") - writer.indent() - - // public static func parse(_ buffer: Buffer) -> Any? - writer.line("public static func parse(_ buffer: Buffer) -> Any? {") - writer.indent() - writer.line("let reader = BufferReader(buffer)") - writer.line("if let signature = reader.readInt32() {") - writer.indent() - writer.line("return parse(reader, signature: signature)") - writer.dedent() - writer.line("}") - writer.line("return nil") - writer.dedent() - writer.line("}") - writer.line() - - // fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any? - writer.line("fileprivate static func parse(_ reader: BufferReader, signature: Int32) -> Any? {") - writer.indent() - writer.line("if let parser = parsers[signature] {") - writer.indent() - writer.line("return parser(reader)") - writer.dedent() - writer.line("}") - writer.line("else {") - writer.indent() - writer.line("telegramApiLog(\"Type constructor \\(String(signature, radix: 16, uppercase: false)) not found\")") - writer.line("return nil") - writer.dedent() - writer.line("}") - writer.dedent() - writer.line("}") - writer.line() - - // fileprivate static func parseVector - writer.line("fileprivate static func parseVector(_ reader: BufferReader, elementSignature: Int32, elementType: T.Type) -> [T]? {") - writer.indent() - writer.line("if let count = reader.readInt32() {") - writer.indent() - writer.line("var array = [T]()") - writer.line("var i: Int32 = 0") - writer.line("while i < count {") - writer.indent() - writer.line("var signature = elementSignature") - writer.line("if elementSignature == 0 {") - writer.indent() - writer.line("if let unboxedSignature = reader.readInt32() {") - writer.indent() - writer.line("signature = unboxedSignature") - writer.dedent() - writer.line("}") - writer.line("else {") - writer.indent() - writer.line("return nil") - writer.dedent() - writer.line("}") - writer.dedent() - writer.line("}") - writer.line("if let item = \(structName).parse(reader, signature: signature) as? T {") - writer.indent() - writer.line("array.append(item)") - writer.dedent() - writer.line("}") - writer.line("else {") - writer.indent() - writer.line("return nil") - writer.dedent() - writer.line("}") - writer.line("i += 1") - writer.dedent() - writer.line("}") - writer.line("return array") - writer.dedent() - writer.line("}") - writer.line("return nil") - writer.dedent() - writer.line("}") - writer.line() - - // public static func serializeObject - writer.line("public static func serializeObject(_ object: Any, buffer: Buffer, boxed: Swift.Bool) {") - writer.indent() - writer.line("switch object {") - for type in sortedTypes { - writer.line("case let _1 as \(structName).\(type.name):") - writer.indent() - writer.line("_1.serialize(buffer, boxed)") - writer.dedent() - } - writer.line("default:") - writer.indent() - writer.line("break") - writer.dedent() - writer.line("}") - writer.dedent() - writer.line("}") - writer.line() - - // Nested public enum { ... } for each type - for type in sortedTypes { - try emitLayeredType(writer: &writer, apiPrefix: apiPrefix, structName: structName, type: type, typeMap: typeMap) - } - - writer.dedent() - writer.line("}") // close public struct - - return (filename, writer.output()) -} -``` - -- [ ] **Step 2: Add `containsInt256` helper** - -Insert directly below `generateLayered` (or above it, before `private static func generateFieldParsing`): - -```swift -private static func containsInt256(_ type: Resolver.TypeReference) -> Bool { - switch type { - case .int256: - return true - case .bareVector(let element), .boxedVector(let element): - return containsInt256(element) - case .int32, .int64, .double, .bytes, .string, .bool, .boolTrue, .bareConstructor, .boxedType: - return false - } -} -``` - -- [ ] **Step 3: Add `emitLayeredType` helper** - -Insert directly below `containsInt256`. This emits a single nested `public enum { ... }` with inline-args cases, a `serialize(_:_:)` method, and `fileprivate parse_*` methods. It mirrors the existing `generate`/`generateImplFile` logic for the type body but: -- renders with `useStructPattern = false` (no `Cons_*` wrapper) directly, -- drops `TypeConstructorDescription` conformance, -- drops the `descriptionFields()` method, -- uses `fileprivate` (not `public`) for `parse_*`, -- nests inside the outer struct writer's indent rather than using a `public extension`: - -```swift -private static func emitLayeredType( - writer: inout CodeWriter, - apiPrefix: String, - structName: String, - type: Resolver.SumType, - typeMap: [QualifiedName: Resolver.SumType] -) throws { - let sortedConstructors = type.constructors.values.sorted(by: { $0.name < $1.name }) - - let indirectPrefix = try type.hasDirectReference(to: [type], typeMap: typeMap) ? "indirect " : "" - writer.line("\(indirectPrefix)public enum \(type.name.value) {") - writer.indent() - - // case () -- inline-args shape - for constructor in sortedConstructors { - var argumentsString = "" - for argument in constructor.arguments { - if case .boolTrue = argument.type { continue } - if !argumentsString.isEmpty { argumentsString.append(", ") } - argumentsString.append(argument.name.camelCased) - argumentsString.append(": ") - // NOTE: layered generator uses structName (e.g. "SecretApi8") as the "apiPrefix" - // for nested-type references, because nested types live inside the struct. - argumentsString.append(typeReferenceRepresentation(structName, argument.type)) - if argument.condition != nil { argumentsString.append("?") } - } - writer.line("case \(constructor.name.value)\(argumentsString.isEmpty ? "" : "(\(argumentsString))")") - } - writer.line() - - // public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) - writer.line("public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) {") - writer.indent() - writer.line("switch self {") - for constructor in sortedConstructors { - var bindString = "" - for argument in constructor.arguments { - if case .boolTrue = argument.type { continue } - if !bindString.isEmpty { bindString.append(", ") } - bindString.append("let ") - bindString.append(argument.name.camelCasedAndEscaped) - } - writer.line("case .\(constructor.name.value)\(bindString.isEmpty ? "" : "(\(bindString))"):") - writer.indent() - writer.line("if boxed {") - writer.indent() - writer.line("buffer.appendInt32(\(Int32(bitPattern: constructor.id)))") - writer.dedent() - writer.line("}") - - for argument in constructor.arguments { - if case .boolTrue = argument.type { continue } - var argumentAccessor = "\(argument.name.camelCasedAndEscaped)" - if let condition = argument.condition { - writer.line("if Int(\(condition.fieldName)) & Int(1 << \(condition.bitIndex)) != 0 {") - writer.indent() - argumentAccessor.append("!") - generateFieldSerialization(writer: &writer, argument: argument, argumentAccessor: argumentAccessor) - writer.dedent() - writer.line("}") - } else { - generateFieldSerialization(writer: &writer, argument: argument, argumentAccessor: argumentAccessor) - } - } - writer.line("break") - writer.dedent() - } - writer.line("}") - writer.dedent() - writer.line("}") - writer.line() - - // fileprivate static func parse_(_ reader: BufferReader) -> ? - for constructor in sortedConstructors { - writer.line("fileprivate static func parse_\(constructor.name.value)(_ reader: BufferReader) -> \(type.name.value)? {") - writer.indent() - if constructor.arguments.contains(where: { if case .boolTrue = $0.type { return false } else { return true } }) { - var argumentIndex = 0 - var argumentCheckString = "" - var argumentCollectionString = "" - for argument in constructor.arguments { - if case .boolTrue = argument.type { continue } - - writer.line("var _\(argumentIndex + 1): \(typeReferenceRepresentation(structName, argument.type))?") - - if let condition = argument.condition { - guard let fieldIndex = constructor.arguments.filter({ if case .boolTrue = $0.type { return false } else { return true } }).firstIndex(where: { $0.name == condition.fieldName }) else { - throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found") - } - writer.line("if Int(_\(fieldIndex + 1)!) & Int(1 << \(condition.bitIndex)) != 0 {") - writer.indent() - try generateFieldParsing(apiPrefix: structName, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)") - writer.dedent() - writer.line("}") - } else { - try generateFieldParsing(apiPrefix: structName, writer: &writer, typeMap: typeMap, argument: argument, argumentAccessor: "_\(argumentIndex + 1)") - } - - if !argumentCheckString.isEmpty { argumentCheckString.append(" && ") } - argumentCheckString.append("_c\(argumentIndex + 1)") - - if !argumentCollectionString.isEmpty { argumentCollectionString.append(", ") } - argumentCollectionString.append("\(argument.name.camelCased): _\(argumentIndex + 1)") - if argument.condition == nil { argumentCollectionString.append("!") } - - argumentIndex += 1 - } - - var checkIndex = 0 - for argument in constructor.arguments { - if case .boolTrue = argument.type { continue } - if let condition = argument.condition { - guard let fieldIndex = constructor.arguments.filter({ if case .boolTrue = $0.type { return false } else { return true } }).firstIndex(where: { $0.name == condition.fieldName }) else { - throw CodeGenerationError(text: "Condition field \(condition.fieldName) not found") - } - writer.line("let _c\(checkIndex + 1) = (Int(_\(fieldIndex + 1)!) & Int(1 << \(condition.bitIndex)) == 0) || _\(checkIndex + 1) != nil") - } else { - writer.line("let _c\(checkIndex + 1) = _\(checkIndex + 1) != nil") - } - checkIndex += 1 - } - - writer.line("if \(argumentCheckString) {") - writer.indent() - writer.line("return \(structName).\(type.name).\(constructor.name.value)\(argumentCollectionString.isEmpty ? "" : "(\(argumentCollectionString))")") - writer.dedent() - writer.line("}") - writer.line("else {") - writer.indent() - writer.line("return nil") - writer.dedent() - writer.line("}") - } else { - writer.line("return \(structName).\(type.name).\(constructor.name.value)") - } - writer.dedent() - writer.line("}") - } - - writer.dedent() - writer.line("}") - writer.line() -} -``` - -**IMPORTANT:** `typeReferenceRepresentation`, `generateFieldSerialization`, and `generateFieldParsing` take an `apiPrefix` parameter that they inject as a prefix on type names (e.g. `"Api.ChatFull"`). For layered output, the prefix becomes the per-layer struct name (`"SecretApi8"`), so inline-args like `media: SecretApi8.DecryptedMessageMedia` render correctly. We pass `structName` (not `apiPrefix`) to these helpers inside the layered emitter. - -Also: `camelCasedAndEscaped` is a private String extension at the top of `CodeGeneration.swift`. The layered emitter uses it as-is — no duplication. - -- [ ] **Step 4: Verify CodeGeneration.swift compiles** - -Run: `cd build-system/SwiftTL && swift build 2>&1 | head -50` -Expected: only `main.swift` errors (unchanged in this task). Any error inside `CodeGeneration.swift` itself means the emitter has a syntax/type issue — fix before proceeding. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/SwiftTL/Sources/SwiftTL/CodeGeneration.swift -git commit -m "$(cat <<'EOF' -SwiftTL: add CodeGenerator.generateLayered for per-layer output - -Emits one {apiPrefix}Layer{N}.swift file per layer: file-scope -dispatch table, public struct {apiPrefix}{N} with parse/parseVector/ -serializeObject, nested public enums for each sum type using the -inline-args shape. Int256 dispatch entry emitted only when a layer's -constructors reference it. Reuses typeReferenceRepresentation / -generateFieldSerialization / generateFieldParsing unchanged, passing -the struct name as the apiPrefix so nested type refs render correctly. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 4: Wire `main.swift` to branch on `ParsedSchema` - -**Files:** -- Modify: `build-system/SwiftTL/Sources/SwiftTL/main.swift:47-98` - -**Goal:** Update the top-level `do { ... }` block to pattern-match on `ParsedSchema`. Flat path uses today's body unchanged; layered path iterates `resolveLayeredTypes` output and calls `generateLayered` once per layer. - -- [ ] **Step 1: Replace the `do { ... } catch let e { ... }` block** - -Replace the existing block from line 47 (`do {`) to line 98 (the closing `}` of the catch) with: - -```swift -do { - let parsedSchema = try DescriptionParser.parse(data: data) - - try FileManager.default.createDirectory(at: URL(fileURLWithPath: outputDirectoryPath), withIntermediateDirectories: true, attributes: nil) - - switch parsedSchema { - case let .flat(constructors, functions): - let resolvedTypes = try Resolver.resolveTypes(constructors: constructors) - var resolvedFunctions = try Resolver.resolveFunctions(types: resolvedTypes, functionDescriptions: functions) - - resolvedFunctions.append(Resolver.Function(name: QualifiedName(namespace: "help", value: "test"), id: 0xc0e202f7, arguments: [], result: .boxedType(QualifiedName(namespace: nil, value: "Bool")))) - - var constructorOrder: [(typeName: QualifiedName, constructorName: String)] = [] - var typeOrder: [(types: [(typeName: QualifiedName, constructorNames: [String])], functions: [QualifiedName])] = [] - - let sortedTypes = resolvedTypes.sorted(by: { $0.name < $1.name }) - - for type in sortedTypes { - for constructor in type.constructors.values.sorted(by: { $0.name < $1.name }) { - constructorOrder.append((type.name, constructor.name.value)) - } - } - - var totalConstructorCount = 0 - var currentConstructorCount = 0 - for type in sortedTypes { - if typeOrder.isEmpty || currentConstructorCount >= 32 { - typeOrder.append(([], [])) - currentConstructorCount = 0 - } - typeOrder[typeOrder.count - 1].types.append((type.name, type.constructors.values.sorted(by: { $0.name < $1.name }).map(\.name.value))) - currentConstructorCount += type.constructors.count - totalConstructorCount += type.constructors.count - if totalConstructorCount > 40 { } - } - - typeOrder.append(([], [])) - for function in resolvedFunctions.sorted(by: { $0.name < $1.name }) { - typeOrder[typeOrder.count - 1].functions.append(function.name) - } - - let generatedFiles = try CodeGenerator.generate(apiPrefix: apiPrefix, types: resolvedTypes, functions: resolvedFunctions, constructorOrder: constructorOrder, typeOrder: typeOrder) - - for (name, fileData) in generatedFiles { - let filePath = URL(fileURLWithPath: outputDirectoryPath).appendingPathComponent(name).path - let _ = try? FileManager.default.removeItem(atPath: filePath) - try fileData.write(toFile: filePath, atomically: true, encoding: .utf8) - } - - case let .layered(layers): - let resolvedLayers = try Resolver.resolveLayeredTypes(layers: layers) - for (layerNumber, types) in resolvedLayers { - let (filename, source) = try CodeGenerator.generateLayered(apiPrefix: apiPrefix, layerNumber: layerNumber, types: types) - let filePath = URL(fileURLWithPath: outputDirectoryPath).appendingPathComponent(filename).path - let _ = try? FileManager.default.removeItem(atPath: filePath) - try source.write(toFile: filePath, atomically: true, encoding: .utf8) - } - } -} catch let e { - print("\(e)") -} -``` - -Note the flat branch body is the same 40-odd lines that were in the original `do`, lightly reindented. The `createDirectory` call is hoisted above the switch since both branches need it. - -- [ ] **Step 2: Verify SwiftTL builds** - -Run: `cd build-system/SwiftTL && swift build 2>&1` -Expected: `Build complete!` with no errors. - -- [ ] **Step 3: Dry-run layered generation on secret_scheme.tl** - -```bash -cd /Users/isaac/build/telegram/telegram-ios/build-system/SwiftTL -rm -rf /tmp/swifttl-layered-out -swift run SwiftTL /Users/isaac/build/telegram/telegram-ios-shared/tools/secret_scheme.tl /tmp/swifttl-layered-out --api-prefix=SecretApi -ls /tmp/swifttl-layered-out/ -``` - -Expected output: 11 files named `SecretApiLayer{8,17,20,23,45,46,66,73,101,143,144}.swift`. If any are missing or extra, inspect the parser's layer-marker handling. If the tool errors, the error message should point at the offending layer or constructor. - -- [ ] **Step 4: Dry-run flat generation on swift_scheme.tl (regression check)** - -```bash -cd /Users/isaac/build/telegram/telegram-ios/build-system/SwiftTL -rm -rf /tmp/swifttl-flat-out -swift run SwiftTL /Users/isaac/build/telegram/telegram-ios-shared/tools/swift_scheme.tl /tmp/swifttl-flat-out -ls /tmp/swifttl-flat-out/ -diff -q /tmp/swifttl-flat-out /Users/isaac/build/telegram/telegram-ios/submodules/TelegramApi/Sources/ 2>&1 | grep -E "^(Only in|Files)" | head -``` - -Expected: 6 files (`Api0.swift` through `Api5.swift`). The diff against `submodules/TelegramApi/Sources/` should show only "Only in submodules" entries for non-`Api*.swift` files (e.g. `SecretApiLayer*.swift`, `Api+*.swift` helpers). `Api0.swift`-`Api5.swift` must either be identical or show only trivially-different content — any structural diff would indicate a regression in the flat pipeline that must be fixed before proceeding. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add build-system/SwiftTL/Sources/SwiftTL/main.swift -git commit -m "$(cat <<'EOF' -SwiftTL: main.swift branches on ParsedSchema - -Flat schemas keep the existing generate(...) pipeline. Layered -schemas iterate resolveLayeredTypes and write one -{apiPrefix}Layer{N}.swift per layer via generateLayered. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 5: Regenerate `SecretApiLayer*.swift` and verify full project build - -**Files:** -- Overwrite: `submodules/TelegramApi/Sources/SecretApiLayer{8,46,73,101,144}.swift` (existing, hand-written) -- Create: `submodules/TelegramApi/Sources/SecretApiLayer{17,20,23,45,66,143}.swift` (new) - -**Goal:** Regenerate all 11 layer files using the new SwiftTL and confirm the entire iOS project still compiles. Downstream consumers (`ManagedSecretChatOutgoingOperations.swift`, `ProcessSecretChatIncomingDecryptedOperations.swift`) already reference `SecretApi{8,46,73,101,144}..` symbols; they must continue to resolve. - -- [ ] **Step 1: Pre-flight — snapshot current state of `submodules/TelegramApi/Sources/SecretApiLayer*.swift`** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -ls submodules/TelegramApi/Sources/SecretApiLayer*.swift -git log --oneline -5 -- submodules/TelegramApi/Sources/SecretApiLayer*.swift -``` - -Expected: 5 files (`SecretApiLayer{8,46,73,101,144}.swift`), each tracked by git. If there are uncommitted modifications to any of these files, stop and investigate — they should be clean before regeneration. - -- [ ] **Step 2: Regenerate into a staging directory** - -```bash -cd /Users/isaac/build/telegram/telegram-ios/build-system/SwiftTL -rm -rf /tmp/secretapi-staging -swift run SwiftTL /Users/isaac/build/telegram/telegram-ios-shared/tools/secret_scheme.tl /tmp/secretapi-staging --api-prefix=SecretApi -ls /tmp/secretapi-staging/ -``` - -Expected: 11 files, one per layer. - -- [ ] **Step 3: Spot-check layer 8 output against the hand-written file** - -```bash -diff /tmp/secretapi-staging/SecretApiLayer8.swift /Users/isaac/build/telegram/telegram-ios/submodules/TelegramApi/Sources/SecretApiLayer8.swift | head -80 -``` - -Expected: cosmetic differences (whitespace, maybe case ordering) but each enum case name must appear in both; each `buffer.appendInt32()` must have the same ID in both files for the same constructor; the `parsers` dict must contain the same set of `dict[]` entries. If the generator added a `Bool` type (from the pre-marker `boolFalse`/`boolTrue`), that's an expected addition per the spec — not a failure. - -If any *constructor ID* or *enum case name* differs for an existing constructor, STOP. That means either the schema's legacy hand-written content drifted from the `.tl` source (spec risk section covers this — schema wins) or the generator has a bug. Decide on the fly: if the legacy hand-written file has a typo'd ID, the regenerated file is correct and should land as-is. If the generator has a bug, fix before proceeding. - -- [ ] **Step 4: Copy staging output into place** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -rm -f submodules/TelegramApi/Sources/SecretApiLayer*.swift -cp /tmp/secretapi-staging/SecretApiLayer*.swift submodules/TelegramApi/Sources/ -ls submodules/TelegramApi/Sources/SecretApiLayer*.swift -``` - -Expected: 11 files, one per layer (5 overwrites + 6 new). - -- [ ] **Step 5: Full Bazel build** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -source ~/.zshrc 2>/dev/null -python3 build-system/Make/Make.py build --continueOnError 2>&1 | tee /tmp/swifttl-wave-build.log | tail -40 -``` - -Expected: build completes with no errors. The `Telegram.ipa` target builds successfully. If compile errors surface, they will most likely be in `ManagedSecretChatOutgoingOperations.swift` or `ProcessSecretChatIncomingDecryptedOperations.swift` — cases where a specific `SecretApi{N}..` symbol the consumer expects doesn't appear in the regenerated file. Triage: - -- If the missing symbol is a constructor present in `secret_scheme.tl` under some layer: verify the resolver captured it correctly in the snapshot for that layer. Likely a bug in `resolveLayeredTypes` (e.g. pre-marker handling) or in the emitter (e.g. case-name mis-generation). Fix in SwiftTL and regenerate. -- If the missing symbol names a constructor NOT in `secret_scheme.tl` under that layer's cumulative set: the hand-written file and the consumer code drifted from the schema. Not a generator bug. Escalate with the user before modifying consumer code. - -- [ ] **Step 6: Commit regenerated files** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TelegramApi/Sources/SecretApiLayer*.swift -git status --short submodules/TelegramApi/Sources/ -git commit -m "$(cat <<'EOF' -TelegramApi: regenerate SecretApiLayer*.swift via SwiftTL - -Replaces the hand-written layer 8/46/73/101/144 files with SwiftTL -output and adds the previously-unpublished layers 17/20/23/45/66/143. -Per-layer struct names (SecretApi8, SecretApi46, ...) and public -enum case signatures are unchanged; downstream consumers -(ManagedSecretChatOutgoingOperations, ProcessSecretChatIncomingDecryptedOperations) -compile unchanged. - -BUILD uses glob("Sources/**/*.swift") so no BUILD update required. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 6: Final flat-path regression check - -**Files:** none modified — this is a verification-only task. - -**Goal:** Confirm the flat pipeline is structurally unchanged — `Api*.swift` files regenerated from `swift_scheme.tl` match what's currently committed in `submodules/TelegramApi/Sources/`. - -- [ ] **Step 1: Regenerate `Api*.swift` into staging** - -```bash -cd /Users/isaac/build/telegram/telegram-ios/build-system/SwiftTL -rm -rf /tmp/api-flat-staging -swift run SwiftTL /Users/isaac/build/telegram/telegram-ios-shared/tools/swift_scheme.tl /tmp/api-flat-staging -ls /tmp/api-flat-staging/ -``` - -Expected: `Api0.swift` through `Api5.swift` (exact count depends on the schema's constructor count; 6 files is the current count). - -- [ ] **Step 2: Diff against committed flat output** - -```bash -for f in /tmp/api-flat-staging/Api*.swift; do - base=$(basename "$f") - diff -q "$f" "/Users/isaac/build/telegram/telegram-ios/submodules/TelegramApi/Sources/$base" || echo "DIFFERS: $base" -done -``` - -Expected: every file identical to the committed version, or at most whitespace differences. Any structural diff (different enum cases, different IDs, different method signatures) is a regression in the flat pipeline introduced by this wave's edits — must be fixed. - -- [ ] **Step 3: (If no diff) confirm in conversation** - -If step 2 reports all files identical, the wave is complete — note in the PR description / commit log that the flat pipeline is verified unchanged. No further commit. - -- [ ] **Step 4: (If diff found) investigate and fix** - -If diffs exist: most likely cause is that a shared helper in `CodeGeneration.swift` was touched with an effect that cascades into `generate(...)`. Revisit Task 3 and ensure all edits added new methods only, with no modifications to `generate`, `generateMainFile`, `generateImplFile`, or the top-level `CodeGenerator` API. Fix and re-run step 2. - ---- - -## Out-of-scope follow-ups (do not execute in this plan) - -Documented in the spec; not part of this plan's delivered scope. - -- Update `/Users/isaac/build/telegram/telegram-ios-shared/tools/generate_and_copy_scheme.sh` to `rm -f` and `cp` the `SecretApiLayer*.swift` output alongside the existing `Api*.swift` copy step. Lives in a sibling repo; the user handles when ready. -- Delete `build-system/SwiftTL/Sources/SwiftTL/LegacyOrderParser.swift` was ALREADY deleted in the uncommitted working tree (per `git status` at plan writing time — `D Sources/SwiftTL/LegacyOrderParser.swift`). Not in this plan; the deletion can land with Task 1 if still convenient or as a separate cleanup. diff --git a/docs/superpowers/plans/2026-04-21-textstyleeditscreen-caret-tracking.md b/docs/superpowers/plans/2026-04-21-textstyleeditscreen-caret-tracking.md deleted file mode 100644 index d404d91583..0000000000 --- a/docs/superpowers/plans/2026-04-21-textstyleeditscreen-caret-tracking.md +++ /dev/null @@ -1,351 +0,0 @@ -# TextStyleEditScreen caret-tracking Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** On every text change inside `TextStyleEditScreen`, scroll the enclosing `ResizableSheetComponent` scroll view so the caret in the active `ListMultilineTextFieldItemComponent` stays visible ~24pt above the keyboard/bottom button area. - -**Architecture:** Single-file change in `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift`. Give each text field a `ListMultilineTextFieldItemComponent.Tag`; at the end of `TextStyleEditContentComponent.View.update(...)`, read `TextFieldComponent.AnimationHint` off the transition's userData; on a `.textChanged` hint, resolve the editing field, compute the caret rect via `UITextInput.caretRect(for:)`, walk `superview` to the enclosing `UIScrollView`, and adjust its `bounds.origin.y` using the direct-assign + additive-animate pattern from `ComposePollScreen.swift:2873-2895`. - -**Tech Stack:** Swift, UIKit, Telegram's ComponentFlow (`ComponentView`, `ComponentTransition`, `TextFieldComponent.AnimationHint`), Bazel via `Make.py`. No unit tests exist in this project — verification is a full build + manual smoke test per `CLAUDE.md`. - -**Reference spec:** `docs/superpowers/specs/2026-04-21-textstyleeditscreen-caret-tracking-design.md`. - -**Reference precedent:** `submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift:2733-2895` (field-bounds variant of this same pattern). - ---- - -## File Structure - -Only one file is touched: - -- **Modify:** `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` - - Add two stored `ListMultilineTextFieldItemComponent.Tag` properties on `TextStyleEditContentComponent.View`. - - Thread those tags into the two existing `ListMultilineTextFieldItemComponent(...)` constructions inside `update(...)`. - - Add a private `recenterCaret(hintView:transition:)` method on `TextStyleEditContentComponent.View`. - - Call `recenterCaret` from the tail of `update(...)` when the transition carries a `.textChanged` `TextFieldComponent.AnimationHint`. - -No other files are modified. Public API of `ResizableSheetComponent`, `ListMultilineTextFieldItemComponent`, and `TextFieldComponent` is used as-is. - ---- - -## Task 1: Add field tags and wire them into the two text field constructors - -**Files:** -- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` (around lines 64-77, 277, 322) - -- [ ] **Step 1: Add the two `Tag` stored properties to `TextStyleEditContentComponent.View`** - -In `TextStyleEditScreen.swift`, locate the stored-property block at the top of `final class View: UIView` (lines 64-77). Below `private let linkOption = ComponentView()` (line 76) add: - -```swift - private let titleFieldTag = ListMultilineTextFieldItemComponent.Tag() - private let textFieldTag = ListMultilineTextFieldItemComponent.Tag() -``` - -Keep them above the `override init(frame: CGRect)` at line 78. - -- [ ] **Step 2: Pass `self.titleFieldTag` into the title field constructor** - -Locate the `ListMultilineTextFieldItemComponent(...)` construction for the title section (starts at line 260). Its last argument currently reads `tag: nil` (line 277). Change it to: - -```swift - tag: self.titleFieldTag -``` - -- [ ] **Step 3: Pass `self.textFieldTag` into the prompt field constructor** - -Locate the second `ListMultilineTextFieldItemComponent(...)` construction for the text section (starts at line 304). Its last argument currently reads `tag: nil` (line 322). Change it to: - -```swift - tag: self.textFieldTag -``` - -- [ ] **Step 4: Verify the change compiles** - -Run: - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -Expected: build succeeds (or the same pre-existing failures unrelated to `TextStyleEditScreen.swift`). A failure in `TextStyleEditScreen.swift` means the tag types or property names are wrong — fix before moving on. - -- [ ] **Step 5: Do not commit yet** — tag wiring is inert without the recenter logic. Defer commit to Task 4. - ---- - -## Task 2: Add the `recenterCaret` helper - -**Files:** -- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` - -- [ ] **Step 1: Add the method on `TextStyleEditContentComponent.View`** - -Inside `final class View: UIView` (the class that starts at line 64), directly **before** the `func update(component:availableSize:state:environment:transition:)` method (line 86), add this private method. It covers steps 1–6 from the design spec (locate field view → caret rect → scroll view → scroll view coordinates → visible region → adjust bounds). - -```swift - private func recenterCaret(hintView: UIView, transition: ComponentTransition) { - var fieldView: ListMultilineTextFieldItemComponent.View? - var ancestor: UIView? = hintView - while let current = ancestor { - if let candidate = current as? ListMultilineTextFieldItemComponent.View { - fieldView = candidate - break - } - ancestor = current.superview - } - guard let fieldView else { - return - } - if !(fieldView.matches(tag: self.titleFieldTag) || fieldView.matches(tag: self.textFieldTag)) { - return - } - guard let inputTextView = fieldView.textFieldView?.inputTextView else { - return - } - let caretPosition = inputTextView.selectedTextRange?.end ?? inputTextView.endOfDocument - let caretRect = inputTextView.caretRect(for: caretPosition) - if caretRect.isNull || caretRect.isInfinite { - return - } - - var scrollAncestor: UIView? = self.superview - var scrollView: UIScrollView? - while let current = scrollAncestor { - if let candidate = current as? UIScrollView { - scrollView = candidate - break - } - scrollAncestor = current.superview - } - guard let scrollView, let environment = self.environment else { - return - } - - let caretInScroll = inputTextView.convert(caretRect, to: scrollView) - - let bottomActionAreaHeight: CGFloat = 60.0 - let caretTopInset: CGFloat = 24.0 - let caretBottomInset: CGFloat = 24.0 - let visibleTop = scrollView.bounds.minY + caretTopInset - let visibleBottom = scrollView.bounds.maxY - environment.inputHeight - bottomActionAreaHeight - caretBottomInset - - let previousBounds = scrollView.bounds - var newBounds = previousBounds - if caretInScroll.maxY > visibleBottom { - newBounds.origin.y += (caretInScroll.maxY - visibleBottom) - } else if caretInScroll.minY < visibleTop { - newBounds.origin.y -= (visibleTop - caretInScroll.minY) - } - let maxOriginY = max(0.0, scrollView.contentSize.height - scrollView.bounds.height) - newBounds.origin.y = min(max(0.0, newBounds.origin.y), maxOriginY) - - if newBounds != previousBounds { - scrollView.bounds = newBounds - if !transition.animation.isImmediate { - let offsetY = previousBounds.origin.y - newBounds.origin.y - transition.animateBoundsOrigin(view: scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) - } - } - } -``` - -Notes on key choices: - -- `bottomActionAreaHeight: 60.0` = `52.0` (bottom item height — see `ResizableSheetComponent.swift:750`) + `8.0` gap above the button (matches `ResizableSheetComponent.swift:732`). -- `caretTopInset` / `caretBottomInset` (both `24.0`) provide the "small inset" biased positioning the user confirmed. -- The hint's view ancestor walk is used (rather than `self.titleFieldTag`'s / `self.textFieldTag`'s views directly) because the hint already carries the `TextFieldComponent.View` that actually fired the change — this is safer than guessing which of our two fields is editing when both may have briefly claimed focus. -- `transition.animateBoundsOrigin` is the proven pattern from `ComposePollScreen.swift:2891-2894`; `transition.animation.isImmediate` gating avoids an unnecessary animation when the transition is immediate. -- Silent bails on missing scroll view or text view keep the code robust against host refactors (they should never happen in normal operation). - -- [ ] **Step 2: Verify compilation** - -Re-run the build command from Task 1 Step 4. Expected: the method compiles cleanly. Common failure modes to watch for: - -- `cannot find 'ListMultilineTextFieldItemComponent.View' in scope` → wrong type path; check the import and the class name in `ListMultilineTextFieldItemComponent.swift:196` (it is the nested `View` class of `ListMultilineTextFieldItemComponent`). -- `value of type 'TextFieldComponent.View' has no member 'inputTextView'` → the property is defined at `TextFieldComponent.swift:359`; ensure you're reading `fieldView.textFieldView?.inputTextView`, not reaching into private internals. -- `'ComponentTransition' has no member 'animateBoundsOrigin'` → this is a ComponentFlow method; grep confirms it exists and is used at `ComposePollScreen.swift:2893`. If missing, the import line (`import ComponentFlow`) at file top is the place to check. - -- [ ] **Step 3: Do not commit yet** — the helper is unreferenced and unused. Defer commit to Task 4. - ---- - -## Task 3: Hook up the `.textChanged` trigger in `update(...)` - -**Files:** -- Modify: `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` - -- [ ] **Step 1: Add the trigger at the tail of `update(...)`** - -At the end of `func update(component:availableSize:state:environment:transition:)` on `TextStyleEditContentComponent.View`, locate lines 455-460: - -```swift - contentHeight += 104.0 - - let _ = alphaTransition - - return CGSize(width: availableSize.width, height: contentHeight) -``` - -Insert the trigger block **before** `return`: - -```swift - contentHeight += 104.0 - - let _ = alphaTransition - - if let hint = transition.userData(TextFieldComponent.AnimationHint.self), case .textChanged = hint.kind, let hintView = hint.view { - self.recenterCaret(hintView: hintView, transition: transition) - } - - return CGSize(width: availableSize.width, height: contentHeight) -``` - -Do NOT match on `.textFocusChanged` — per the user's requirement, scrolling fires only on text edits. - -- [ ] **Step 2: Ensure `TextFieldComponent` is importable** - -`TextFieldComponent.AnimationHint` is vended from the `TextFieldComponent` module. Check the file's import list at the top (lines 1-25). `TextFieldComponent` is used transitively today via `ListMultilineTextFieldItemComponent`, but the type is only re-exposed if we explicitly import it. - -Locate the import block (around lines 1-25). If `import TextFieldComponent` is not present, add it alphabetically — for example, between `import ResizableSheetComponent` and `import TelegramCore`: - -```swift -import TextFieldComponent -``` - -If it is already present, skip this sub-step. - -- [ ] **Step 3: Ensure the BUILD dep is present** - -Locate the sibling `BUILD` file: - -```bash -cat submodules/TelegramUI/Components/TextProcessingScreen/BUILD -``` - -Look for `//submodules/TelegramUI/Components/TextFieldComponent:TextFieldComponent` in the `deps` list. If present, skip to the next step. If absent, add it to the `deps` array (preserving alphabetical order where the BUILD file follows that convention). For example: - -``` - "//submodules/TelegramUI/Components/TextFieldComponent:TextFieldComponent", -``` - -- [ ] **Step 4: Verify compilation** - -Re-run the build command from Task 1 Step 4. - -Expected: clean build for `TextStyleEditScreen.swift` and its host module (`TextProcessingScreen`). Common failure modes: - -- `cannot find 'TextFieldComponent' in scope` → missing `import TextFieldComponent` (fix in Step 2). -- Bazel link error naming `TextFieldComponent` → missing BUILD dep (fix in Step 3). -- `instance method requires the types 'X' and 'Y' to be equivalent` on the `case .textChanged = hint.kind` line → the `case let` pattern binding; verify with `grep -n 'case \\.textChanged' submodules/TelegramUI/Components/TextFieldComponent/Sources/TextFieldComponent.swift` that the case is payload-less (it is, per `TextFieldComponent.swift:95-103` where `Kind` declares `case textChanged` without associated values and `case textFocusChanged(isFocused: Bool)` with one). - -- [ ] **Step 5: Do not commit yet** — verify end-to-end behavior in Task 4 first. - ---- - -## Task 4: Manual smoke test and commit - -**Files:** -- Modify (commit): `submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift` -- Possibly modify (commit): `submodules/TelegramUI/Components/TextProcessingScreen/BUILD` - -- [ ] **Step 1: Launch the app on the simulator** - -Run: - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: `Telegram.ipa` target built successfully, 0 errors. - -Note: this project has no unit tests; feature correctness for UI changes requires a manual check on device or simulator. Install the built app on the iOS simulator (`xcrun simctl install booted ...` if not done by the build script) and navigate to the AI style-edit sheet — this is typically reached from a chat's AI compose-mode style selector or from Settings, depending on build flavour. If the entry point is unclear, grep for `TextStyleEditScreen(` to find a test harness or the production call site: - -```bash -grep -rn "TextStyleEditScreen(" submodules --include="*.swift" -``` - -- [ ] **Step 2: Smoke test — short content path** - -1. Tap the "Style Name" field. Confirm the keyboard slides up and the "Create" button rides above the keyboard (pre-existing behavior from the earlier `inputHeight` work). -2. Type one character. With short content no scroll should occur; the scroll view should remain at origin zero (visual check: the emoji icon at the top stays visible). - -Pass criterion: no visual regression; the title field is visible and typable. - -- [ ] **Step 3: Smoke test — long prompt path** - -1. Tap the "Instructions" field. -2. Type enough text (or paste a paragraph) to make the prompt field taller than the viewport with the keyboard up. -3. Continue typing so new characters appear at the caret. - -Pass criterion: as each newline is added, the caret stays approximately 24pt above the keyboard/button area. The field's top may scroll out of view — that's expected. - -- [ ] **Step 4: Smoke test — manual-scroll-then-type** - -1. Still in the "Instructions" field with enough content that scroll is possible. -2. Manually drag the sheet content up so the caret is pushed above the visible area. -3. Type one character. - -Pass criterion: the scroll view snaps downward so the caret is visible again, above the keyboard with the configured inset. - -- [ ] **Step 5: Smoke test — edit-mode mid-field tap (non-goal regression check)** - -1. Trigger the screen in edit mode on a style with a long pre-populated prompt (enough text to exceed the viewport). -2. Tap **in the middle** of the prompt so the caret lands off-screen-top (no text change). - -Pass criterion: **no** scroll occurs (this is per the non-goal — we only scroll on text change). A follow-up text edit is expected to trigger a scroll; that is covered by Step 3. - -- [ ] **Step 6: Check for regressions in adjacent flows** - -Briefly exercise: - -1. The emoji-selection sheet (tap the big round emoji area at the top) — must still open, select, and dismiss without issue. -2. The "Add a link to my account" checkbox — toggling still flips the check. -3. The "Delete Style" row (edit mode) — still pushes the confirm alert. - -Pass criterion: all three work as before. - -- [ ] **Step 7: Commit** - -```bash -git add submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextStyleEditScreen.swift -# Only stage the BUILD file if it was modified in Task 3 Step 3: -git status --short submodules/TelegramUI/Components/TextProcessingScreen/BUILD -# If the BUILD file shows up modified, stage it too: -git add submodules/TelegramUI/Components/TextProcessingScreen/BUILD -git commit -m "$(cat <<'EOF' -TextStyleEditScreen: scroll caret into view on text change - -Tag both ListMultilineTextFieldItemComponents and, at the tail of -TextStyleEditContentComponent.View.update(...), read TextFieldComponent. -AnimationHint off the transition userData. On a .textChanged hint, locate -the editing field, compute the caret rect, walk up to the enclosing -ResizableSheetComponent scroll view, and adjust bounds.origin.y so the -caret sits ~24pt above the keyboard/bottom action area. - -Scroll runs only on text edits (not on focus/selection changes) per spec. -Uses the direct-assign + additive-animate pattern from ComposePollScreen. -EOF -)" -``` - -Expected: commit succeeds. The diff is ~50 lines added across one .swift file (and possibly one line added to BUILD). - ---- - -## Out-of-scope / follow-ups - -None planned. The non-goals called out in the spec (scroll on focus change, scroll on selection change, scroll on keyboard show/hide independently of a text edit) are intentional omissions, not deferred work. - -If manual smoke testing reveals that focus-gain keyboard appearance creates a bad UX (user taps a field near the bottom and the keyboard covers it until they type), consider adding back the `.textFocusChanged(isFocused: true)` case in the trigger block. That is a one-line change to the conditional in Task 3 Step 1 and does not require any design iteration. diff --git a/docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md deleted file mode 100644 index 8732b7a790..0000000000 --- a/docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md +++ /dev/null @@ -1,944 +0,0 @@ -# Wave 36: `ContactListPeer.peer: Peer → EnginePeer` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the public enum case `ContactListPeer.peer(peer: Peer, isGlobal: Bool, participantCount: Int32?)` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum in a single atomic commit. Cascading changes: change `ContactListPeer.indexName` return type from `PeerIndexNameRepresentation` to `EnginePeer.IndexName` (drops 2 `EnginePeer.IndexName(...)` wraps at one call site); rewrite the enum's custom `==` to use `EnginePeer`'s synthesized Equatable; drop 20 outflow `._asPeer()` bridges, 16 inflow `EnginePeer(peer)` wraps; rewrite 2 Postbox-concrete cast chains to EnginePeer case patterns. - -**Architecture:** One atomic commit. The enum-case payload change is necessarily atomic. `ContactListPeer` lives in `submodules/AccountContext/Sources/ContactSelectionController.swift`; 7 consumer files touched in addition. 2 consumer files verified untouched (`ComposeController.swift`, `ChatSendAudioMessageContextPreview.swift`). No new wrappers, no new typealiases. `import Postbox` stays in every touched consumer (follow-up unused-import sweep handles it). - -**Tech Stack:** Swift, Bazel build via Make.py wrapper. No tests — verification is build success + targeted grep checks. - -**Spec:** `docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md` - ---- - -## File Structure - -**Modified files (8 expected — 1 definition + 7 consumer. Plus 2 verify-only.)** - -| File | Edits | Categories | -|---|---|---| -| `submodules/AccountContext/Sources/ContactSelectionController.swift` | 3 (case type + indexName return type + `==` body) | α | -| `submodules/ContactListUI/Sources/ContactListNode.swift` | ~21 (12 outflow + 4 inflow + 2 cast rewrites [L182-186, L1968] + 2 IndexName wraps [L517]) | β + δ + φ + ε′ | -| `submodules/ContactListUI/Sources/ContactsController.swift` | 1 (inflow wrap at L294) | δ | -| `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift` | 7 (3 outflow + 4 inflow) | β + δ | -| `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` | 6 (2 outflow + 4 inflow) | β + δ | -| `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift` | 2 (1 outflow + 1 inflow) | β + δ | -| `submodules/TelegramUI/Sources/ContactSelectionController.swift` | 2 (inflow wraps L517/527) | δ | -| `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift` | 2 (outflow bridges L160/230) | β | - -**Verify-only (no edits expected):** - -| File | Reason | -|---|---| -| `submodules/TelegramUI/Sources/ComposeController.swift` | Destructures at L120/160 access `.id` only. Same-type access works on EnginePeer. | -| `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` | Only holds `[ContactListPeer]` at collection level; no `.peer` destructures. | - -**EnginePeer enum case mapping (used in cast rewrites):** - -| Postbox concrete | EnginePeer case | -|---|---| -| `TelegramUser` | `.user(TelegramUser)` | -| `TelegramGroup` | `.legacyGroup(TelegramGroup)` | -| `TelegramChannel` | `.channel(TelegramChannel)` | - -**Sites that stay as `._asPeer()` bridges (NOT in wave scope):** - -- `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift:488, 528, 562` — `canSendMessagesToPeer(peer._asPeer())` / `canSendMessagesToPeer(peer.peer._asPeer())`. `canSendMessagesToPeer(_: Peer)` migration is a deferred future wave. -- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift:171, 201, 748` — `peerTokenTitle(accountPeerId:..., peer: peer._asPeer(), strings:...)`. `peerTokenTitle(peer: Peer)` migration is out of scope. - ---- - -## Task 1: Edit `AccountContext/Sources/ContactSelectionController.swift` — definition - -**Files:** -- Modify: `submodules/AccountContext/Sources/ContactSelectionController.swift` - -Foundational change. Without it, none of the consumer edits compile. - -- [ ] **Step 1.1: Update the case payload type, `indexName` return type, and `==` operator body** - -Edit using the Edit tool: - -```swift -// OLD (lines 61-99) -public enum ContactListPeer: Equatable { - case peer(peer: Peer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { - switch self { - case let .peer(peer, _, _): - return .peer(peer.id) - case let .deviceContact(id, _): - return .deviceContact(id) - } - } - - public var indexName: PeerIndexNameRepresentation { - switch self { - case let .peer(peer, _, _): - return peer.indexName - case let .deviceContact(_, contact): - return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "") - } - } - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer.isEqual(rhsPeer), lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { - return false - } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { - return true - } else { - return false - } - } - } -} -``` - -```swift -// NEW -public enum ContactListPeer: Equatable { - case peer(peer: EnginePeer, isGlobal: Bool, participantCount: Int32?) - case deviceContact(DeviceContactStableId, DeviceContactBasicData) - - public var id: ContactListPeerId { - switch self { - case let .peer(peer, _, _): - return .peer(peer.id) - case let .deviceContact(id, _): - return .deviceContact(id) - } - } - - public var indexName: EnginePeer.IndexName { - switch self { - case let .peer(peer, _, _): - return peer.indexName - case let .deviceContact(_, contact): - return .personName(first: contact.firstName, last: contact.lastName, addressNames: [], phoneNumber: "") - } - } - - public static func ==(lhs: ContactListPeer, rhs: ContactListPeer) -> Bool { - switch lhs { - case let .peer(lhsPeer, lhsIsGlobal, lhsParticipantCount): - if case let .peer(rhsPeer, rhsIsGlobal, rhsParticipantCount) = rhs, lhsPeer == rhsPeer, lhsIsGlobal == rhsIsGlobal, lhsParticipantCount == rhsParticipantCount { - return true - } else { - return false - } - case let .deviceContact(id, contact): - if case .deviceContact(id, contact) = rhs { - return true - } else { - return false - } - } - } -} -``` - -Three changes in this edit: -1. Line 62: `peer: Peer` → `peer: EnginePeer` -2. Line 74: return type `PeerIndexNameRepresentation` → `EnginePeer.IndexName` -3. Line 86 (inside the `==` operator): `lhsPeer.isEqual(rhsPeer)` → `lhsPeer == rhsPeer` - -`EnginePeer.IndexName.personName(first:last:addressNames:phoneNumber:)` has the same labels/types as `PeerIndexNameRepresentation.personName`, so line 79 body is untouched — only its return target enum changes. - -- [ ] **Step 1.2: Verify** - -Run: - -```bash -grep -nE "case peer\(peer:|public var indexName:|\.isEqual\(" submodules/AccountContext/Sources/ContactSelectionController.swift -``` - -Expected output: -- Line 62: `case peer(peer: EnginePeer, ...)` -- Line 74: `public var indexName: EnginePeer.IndexName {` -- No `isEqual(` match on the `==` path (the only remaining occurrences would be unrelated). - -Do not commit yet. - ---- - -## Task 2: Edit `ContactListNode.swift` — largest consumer, multi-category - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactListNode.swift` - -Most changes happen here: 12 outflow bridges + 4 inflow wraps + 2 cast chain rewrites + 2 IndexName wrap drops. - -- [ ] **Step 2.1: Drop the 12 outflow `._asPeer()` bridges via `replace_all`** - -All 12 `._asPeer()` bridges at ContactListPeer.peer construction sites follow the shape `._asPeer(), isGlobal:`. Non-construction `._asPeer()` uses in this file (if any) feed other functions and do NOT use this exact substring. - -Pre-flight verify: - -```bash -grep -cE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -Expected: `12`. - -If the count is 12, apply the Edit tool with `replace_all=true`: -- `old_string`: `._asPeer(), isGlobal:` -- `new_string`: `, isGlobal:` - -If the count is not 12, fall back to per-site Edits at lines 632, 690, 701, 747, 765, 1365, 1647, 1656, 1693, 1731, 1942, 1944 using enough surrounding context to make each `old_string` unique. - -- [ ] **Step 2.2: Verify the 12 outflow drops** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -Expected: zero matches. - -- [ ] **Step 2.3: Drop 2 inflow wraps at L204** - -Read lines 200–210 first to confirm the line text. - -Edit: - -```swift -// OLD (line 204) - itemPeer = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer)) -``` - -```swift -// NEW - itemPeer = .peer(peer: peer, chatPeer: peer) -``` - -- [ ] **Step 2.4: Drop 1 inflow wrap at L252** - -Read lines 248–256 first to confirm. - -Edit: - -```swift -// OLD (line 252) - interaction.openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -```swift -// NEW - interaction.openDisabledPeer(peer, requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -- [ ] **Step 2.5: Drop 1 inflow wrap at L844** - -Read lines 840–848 first to confirm. - -Edit: - -```swift -// OLD (line 844) - if let isPeerEnabled, !isPeerEnabled(EnginePeer(peer)) { -``` - -```swift -// NEW - if let isPeerEnabled, !isPeerEnabled(peer) { -``` - -- [ ] **Step 2.6: Rewrite the L182-186 cast chain to EnginePeer case patterns** - -Read lines 176–200 first. The cast chain is inside the ContactListPeer.peer destructure at line 177. - -Edit: - -```swift -// OLD (lines 182-186) - } else { - if let _ = peer as? TelegramUser { - status = .presence(presence ?? EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0), dateTimeFormat) - } else if let group = peer as? TelegramGroup { - status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil) - } else if let channel = peer as? TelegramChannel { -``` - -```swift -// NEW - } else { - if case .user = peer { - status = .presence(presence ?? EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0), dateTimeFormat) - } else if case let .legacyGroup(group) = peer { - status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil) - } else if case let .channel(channel) = peer { -``` - -`channel.info` access inside the surviving inner block continues to compile unchanged (`EnginePeer.channel` wraps `TelegramChannel`). `group.participantCount` inside the `legacyGroup` branch works identically. The first branch doesn't bind the user — the `case .user = peer` form preserves that. - -- [ ] **Step 2.7: Rewrite the L1968 cast to an EnginePeer case pattern** - -Read lines 1964–1976 first. The cast is inside the ContactListPeer.peer destructure at line 1966. - -Edit: - -```swift -// OLD (lines 1967-1968) - if requirePhoneNumbers, - let user = peer as? TelegramUser { -``` - -```swift -// NEW - if requirePhoneNumbers, - case let .user(user) = peer { -``` - -`user.phone` on the following line continues to compile (`EnginePeer.user` wraps `TelegramUser`). - -- [ ] **Step 2.8: Drop 2 IndexName wraps at L517** - -Read lines 515–522 first. - -Edit: - -```swift -// OLD (line 517) - let result = EnginePeer.IndexName(lhs.indexName).isLessThan(other: EnginePeer.IndexName(rhs.indexName), ordering: sortOrder) -``` - -```swift -// NEW - let result = lhs.indexName.isLessThan(other: rhs.indexName, ordering: sortOrder) -``` - -`ContactListPeer.indexName` now returns `EnginePeer.IndexName` (from Task 1), and `isLessThan(other:ordering:)` is defined on `EnginePeer.IndexName` at `submodules/LocalizedPeerData/Sources/PeerTitle.swift:64`, so the wrap idiom is no longer required. - -- [ ] **Step 2.9: Verify ContactListNode.swift changes** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)|peer as\? Telegram(User|Group|Channel)\b|EnginePeer\.IndexName\(lhs\.indexName\)|EnginePeer\.IndexName\(rhs\.indexName\)" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -Expected output: only `EnginePeer(peer)` matches at lines 1819 and 1825 (out-of-scope; `peer` there is from `entryData.renderedPeer.peer`, raw `Peer`, wraps stay). Similarly, `peer as? TelegramChannel` at 1802/1820 and `peer is TelegramGroup` at 1818 stay. - -If any other match appears, re-examine that site and apply the matching fix. - ---- - -## Task 3: Edit `ContactsController.swift` — 1 inflow wrap drop - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactsController.swift` - -- [ ] **Step 3.1: Drop inflow wrap at L294** - -Read lines 285–300 first. - -Edit: - -```swift -// OLD (line 294) - strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(EnginePeer(peer)), purposefulAction: { [weak self] in -``` - -```swift -// NEW - strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), purposefulAction: { [weak self] in -``` - -`peer` here is destructured from the ContactListPeer.peer case at line 287; post-migration it is already `EnginePeer`. `chatLocation: .peer(EnginePeer)` case takes `EnginePeer`. - -- [ ] **Step 3.2: Verify** - -Run: - -```bash -grep -nE "chatLocation: \.peer\(EnginePeer\(peer\)\)" submodules/ContactListUI/Sources/ContactsController.swift -``` - -Expected: zero matches. - ---- - -## Task 4: Edit `ContactsSearchContainerNode.swift` — 3 outflow + 4 inflow - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift` - -- [ ] **Step 4.1: Drop the 3 outflow `._asPeer()` bridges at L494/535/569** - -Use the same `._asPeer(), isGlobal:` pattern as Task 2.1. The 3 bridges at `ContactListPeer.peer(...)` constructions all match this substring; the 3 unrelated bridges at L488/528/562 (`canSendMessagesToPeer(...)` sites) do NOT match (they lack the `, isGlobal:` suffix). - -Pre-flight verify: - -```bash -grep -cE "\._asPeer\(\), isGlobal:" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift -``` - -Expected: `3`. - -Apply Edit with `replace_all=true`: -- `old_string`: `._asPeer(), isGlobal:` -- `new_string`: `, isGlobal:` - -- [ ] **Step 4.2: Drop 4 inflow wraps at L164/165/181** - -Read lines 160–185 first. - -Three edits, each targeting one source line. - -Edit (line 164 — 2 wraps in one expression): - -```swift -// OLD - peerItem = .peer(peer: EnginePeer(peer), chatPeer: EnginePeer(peer)) -``` - -```swift -// NEW - peerItem = .peer(peer: peer, chatPeer: peer) -``` - -Edit (line 165): - -```swift -// OLD - nativePeer = EnginePeer(peer) -``` - -```swift -// NEW - nativePeer = peer -``` - -Edit (line 181): - -```swift -// OLD - openDisabledPeer(EnginePeer(peer), requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -```swift -// NEW - openDisabledPeer(peer, requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -- [ ] **Step 4.3: Verify** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift -``` - -Expected: zero matches. - -The `._asPeer()` calls at L488/528/562 (feeding `canSendMessagesToPeer`) should remain. Verify: - -```bash -grep -nE "canSendMessagesToPeer\(.*\._asPeer\(\)\)" submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift -``` - -Expected: 3 matches (L488, L528, L562). - ---- - -## Task 5: Edit `TelegramUI/Sources/ContactMultiselectionController.swift` — 2 outflow + 4 inflow - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` - -- [ ] **Step 5.1: Drop 2 outflow bridges at L451/459 via `replace_all`** - -Pre-flight verify: - -```bash -grep -cE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: `2`. - -Apply Edit with `replace_all=true`: -- `old_string`: `._asPeer(), isGlobal:` -- `new_string`: `, isGlobal:` - -Unrelated `._asPeer()` calls at L171/201/748 (feeding `peerTokenTitle(peer: Peer, ...)`) do NOT use this substring and stay. - -- [ ] **Step 5.2: Drop 4 inflow wraps at L386/403/481/491** - -Read the file around each site to confirm exact text. Two wraps (L386, L403) have identical text; the other two (L481, L491) have distinct tails. - -Edit for L386 and L403 — `replace_all=true` on the substring: - -Pre-flight verify: - -```bash -grep -cE "subject: \.peer\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: `2`. - -Apply Edit with `replace_all=true`: -- `old_string`: `subject: .peer(EnginePeer(peer))` -- `new_string`: `subject: .peer(peer)` - -Edit for L481: - -```swift -// OLD - self.params.sendMessage?(EnginePeer(peer)) -``` - -```swift -// NEW - self.params.sendMessage?(peer) -``` - -Edit for L491: - -```swift -// OLD - self.params.openProfile?(EnginePeer(peer)) -``` - -```swift -// NEW - self.params.openProfile?(peer) -``` - -- [ ] **Step 5.3: Verify** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:|subject: \.peer\(EnginePeer\(peer\)\)|sendMessage\?\(EnginePeer\(peer\)\)|openProfile\?\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: zero matches. - -Preserved bridge sites (sanity check): - -```bash -grep -nE "peerTokenTitle\(.*\._asPeer\(\)" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: 3 matches (L171, L201, L748). - ---- - -## Task 6: Edit `TelegramUI/Sources/ContactMultiselectionControllerNode.swift` — 1 outflow + 1 inflow - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift` - -- [ ] **Step 6.1: Drop 1 outflow bridge at L317** - -Read lines 315–320 first. - -Edit: - -```swift -// OLD (line 317) - self?.openPeer?(.peer(peer: peer._asPeer(), isGlobal: false, participantCount: nil)) -``` - -```swift -// NEW - self?.openPeer?(.peer(peer: peer, isGlobal: false, participantCount: nil)) -``` - -- [ ] **Step 6.2: Drop 1 inflow wrap at L492** - -Read lines 488–495 first. - -Edit: - -```swift -// OLD (line 492) - callTitle = self.presentationData.strings.NewCall_ActionCallSingle(EnginePeer(peer).compactDisplayTitle).string -``` - -```swift -// NEW - callTitle = self.presentationData.strings.NewCall_ActionCallSingle(peer.compactDisplayTitle).string -``` - -- [ ] **Step 6.3: Verify** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:|EnginePeer\(peer\)\.compactDisplayTitle" submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift -``` - -Expected: zero matches. - ---- - -## Task 7: Edit `TelegramUI/Sources/ContactSelectionController.swift` — 2 inflow wraps - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactSelectionController.swift` - -- [ ] **Step 7.1: Drop 2 inflow wraps at L517/527** - -Read lines 510–535 first. Both sites are inside the destructure at L504. - -Edit for L517: - -```swift -// OLD - self.sendMessage?(EnginePeer(peer)) -``` - -```swift -// NEW - self.sendMessage?(peer) -``` - -Edit for L527: - -```swift -// OLD - self.openProfile?(EnginePeer(peer)) -``` - -```swift -// NEW - self.openProfile?(peer) -``` - -- [ ] **Step 7.2: Verify** - -Run: - -```bash -grep -nE "sendMessage\?\(EnginePeer\(peer\)\)|openProfile\?\(EnginePeer\(peer\)\)" submodules/TelegramUI/Sources/ContactSelectionController.swift -``` - -Expected: zero matches. - ---- - -## Task 8: Edit `TelegramUI/Sources/ContactSelectionControllerNode.swift` — 2 outflow bridges - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift` - -- [ ] **Step 8.1: Drop 2 outflow bridges at L160/230 via `replace_all`** - -Pre-flight verify: - -```bash -grep -cE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift -``` - -Expected: `2`. - -Apply Edit with `replace_all=true`: -- `old_string`: `._asPeer(), isGlobal:` -- `new_string`: `, isGlobal:` - -- [ ] **Step 8.2: Verify** - -Run: - -```bash -grep -nE "\._asPeer\(\), isGlobal:" submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift -``` - -Expected: zero matches. - ---- - -## Task 9: Verify no-edit consumer files - -**Files (read only):** -- Read: `submodules/TelegramUI/Sources/ComposeController.swift` -- Read: `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` - -- [ ] **Step 9.1: Confirm ComposeController.swift has no inflow wraps, casts, or outflow bridges** - -Run: - -```bash -grep -nE "\.peer\(peer:|EnginePeer\(peer\)|peer as\? Telegram|\._asPeer\(\)" submodules/TelegramUI/Sources/ComposeController.swift -``` - -Expected: zero matches (destructures at L120/160 only access `.id`). - -If any match appears, add the appropriate fix step here and re-run Task 9.1 before proceeding. - -- [ ] **Step 9.2: Confirm ChatSendAudioMessageContextPreview.swift has no ContactListPeer.peer destructures** - -Run: - -```bash -grep -nE "case let \.peer\(peer, _, _\)|case \.peer\(let peer|EnginePeer\(peer\)|\.peer\(peer: " submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift -``` - -Expected: zero matches. The file only references `[ContactListPeer]` at the collection level. - ---- - -## Task 10: Build verification (first pass) - -- [ ] **Step 10.1: Run the full build with `--continueOnError`** - -Run: - -```bash -source ~/.zshrc 2>/dev/null && python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError 2>&1 | tee /tmp/wave36-build.log -``` - -Expected outcome: ideally clean. Realistic: 0–3 inventory-missed sites (wave 35 trend was 14% miss rate on a 7-file wave; this 8-file wave has a larger surface area, so budget for up to 3 misses). - -- [ ] **Step 10.2: Triage build errors** - -Likely patterns and fixes: - -| Error | Fix | -|---|---| -| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer'` at a call site | Add `._asPeer()` bridge. The callee takes raw `Peer` and is out of wave scope. | -| `cannot convert value of type 'Peer' to expected argument type 'EnginePeer'` at a `.peer(peer:, ...)` construction | Wrap raw peer with `EnginePeer(...)`. The raw-Peer source is probably from `transaction.getPeer(...)` or similar. | -| `value of type 'EnginePeer' has no member 'isEqual'` | Replace with `==`. | -| `type 'EnginePeer' cannot be cast to 'TelegramUser'` / `TelegramGroup` / `TelegramChannel` | Missed φ-category cast — rewrite to `case .user = peer` / `case let .legacyGroup(x) = peer` / `case let .channel(x) = peer`. | -| `cannot invoke initializer for type 'EnginePeer' with an argument list of type '(EnginePeer)'` | Missed inflow drop — strip `EnginePeer(...)` wrap. | -| `cannot convert value of type 'EnginePeer.IndexName' to expected argument type 'PeerIndexNameRepresentation'` | Either wrap the call site's expected-type change or adjust the consumer to accept `EnginePeer.IndexName`. Probably rare — ContactListPeer.indexName consumers were grepped in pre-flight and found only in ContactListNode. | -| `value of type 'EnginePeer' has no member ''` | That method is only on the Postbox `Peer` protocol. Bridge via `._asPeer()` OR find the EnginePeer-native equivalent. | - -For each error: identify file:line, apply the fix, re-run the build until clean. - -- [ ] **Step 10.3: Iterate to clean build** - -Re-run the build after each batch of fixes. The wave is complete when the build returns 0 errors for the targeted configuration. - -If 10+ unexpected errors surface, halt and reassess: the inventory may have significantly undercounted and the wave may need to be split. Discuss with the user before continuing. - ---- - -## Task 11: Post-build grep validations - -- [ ] **Step 11.1: Outflow-bridge-drop validation** - -Run: - -```bash -grep -rnE "\.peer\(peer: \w+\._asPeer\(\), isGlobal:" submodules/ --include="*.swift" -``` - -Expected: zero hits. Any remaining site is a missed outflow-bridge drop. - -- [ ] **Step 11.2: Inflow-wrap-drop validation** - -Run: - -```bash -for f in submodules/ContactListUI/Sources/ContactListNode.swift \ - submodules/ContactListUI/Sources/ContactsController.swift \ - submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift \ - submodules/TelegramUI/Sources/ContactMultiselectionController.swift \ - submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift \ - submodules/TelegramUI/Sources/ContactSelectionController.swift; do - echo "=== $f ===" - grep -nE "EnginePeer\(peer\)" "$f" -done -``` - -Expected hits: -- ContactListNode.swift L1819, L1825 (raw `renderedPeer.peer`, out-of-scope wraps stay) -- Any other hit in the 6 listed files is a missed inflow drop — inspect and fix. - -- [ ] **Step 11.3: Cast-rewrite validation** - -Run: - -```bash -grep -nE "\bpeer (as\?|as!|is) Telegram(User|Group|Channel)\b" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -Expected: only L1802, L1818, L1820 remain (out-of-scope, `peer` is raw from `renderedPeer.peer`). - -If L182, L184, L186, or L1968 appear, those are missed φ rewrites. - -- [ ] **Step 11.4: IndexName wrap validation** - -Run: - -```bash -grep -nE "EnginePeer\.IndexName\(lhs\.indexName\)|EnginePeer\.IndexName\(rhs\.indexName\)" submodules/ContactListUI/Sources/ContactListNode.swift -``` - -Expected: zero matches. - -- [ ] **Step 11.5: isEqual-in-==-operator validation** - -Run: - -```bash -grep -nE "lhsPeer\.isEqual\(rhsPeer\)" submodules/AccountContext/Sources/ContactSelectionController.swift -``` - -Expected: zero matches. - -- [ ] **Step 11.6: Construction-site sanity sweep** - -Run: - -```bash -grep -rnE "ContactListPeer\.peer\(peer: |\.peer\(peer: \w+, isGlobal:" submodules/ --include="*.swift" | head -40 -``` - -Inspect each hit. Expected forms: -- `.peer(peer: , isGlobal: …)` where `` is either a local already typed `EnginePeer` or `EnginePeer()`. -- Anything of the form `.peer(peer: , isGlobal: …)` where `` is a Postbox `Peer` value is a miss (would surface as a build error — this is a belt-and-suspenders check). - -If any validation fails, return to Task 10. - ---- - -## Task 12: Atomic commit + memory + log update - -- [ ] **Step 12.1: Stage and review** - -Run: - -```bash -git status --short -git diff --stat -``` - -Confirm exactly 8 modified Swift files: -- `submodules/AccountContext/Sources/ContactSelectionController.swift` -- `submodules/ContactListUI/Sources/ContactListNode.swift` -- `submodules/ContactListUI/Sources/ContactsController.swift` -- `submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift` -- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` -- `submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift` -- `submodules/TelegramUI/Sources/ContactSelectionController.swift` -- `submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift` - -Pre-existing WIP (`build-system/bazel-rules/sourcekit-bazel-bsp`, `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/` / `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md`) should NOT be staged. - -- [ ] **Step 12.2: Stage only the wave-36 files** - -Run: - -```bash -git add submodules/AccountContext/Sources/ContactSelectionController.swift \ - submodules/ContactListUI/Sources/ContactListNode.swift \ - submodules/ContactListUI/Sources/ContactsController.swift \ - submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift \ - submodules/TelegramUI/Sources/ContactMultiselectionController.swift \ - submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift \ - submodules/TelegramUI/Sources/ContactSelectionController.swift \ - submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift -``` - -If Task 10 introduced additional files (inventory-miss fixes), append them. - -- [ ] **Step 12.3: Commit** - -Run: - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 36: ContactListPeer.peer Peer -> EnginePeer - -Migrates the public enum case `ContactListPeer.peer(peer: Peer, isGlobal:, -participantCount:)` from the Postbox `Peer` protocol to the TelegramCore -`EnginePeer` enum. Also cascades `ContactListPeer.indexName` return type -from `PeerIndexNameRepresentation` to `EnginePeer.IndexName` and rewrites -the enum's custom `==` operator to use EnginePeer's synthesized Equatable. - -Consumer-side cascade in 7 files: - - 20 `._asPeer()` outflow bridge-drops at ContactListPeer.peer - construction sites (the payload is now EnginePeer) - - 16 `EnginePeer(peer)` inflow wrap-drops at destructure sites (the - destructured `peer` is already EnginePeer) - - 2 `EnginePeer.IndexName(...)` wrap-drops at a sort-comparator (the - indexName property now returns EnginePeer.IndexName directly) - - 2 Postbox-concrete cast chains rewritten to EnginePeer case patterns - (`peer as? TelegramUser` → `case .user = peer`, etc.) - - `lhsPeer.isEqual(rhsPeer)` → `lhsPeer == rhsPeer` in the ==operator - -Files modified: - submodules/AccountContext/Sources/ContactSelectionController.swift - submodules/ContactListUI/Sources/ContactListNode.swift - submodules/ContactListUI/Sources/ContactsController.swift - submodules/ContactListUI/Sources/ContactsSearchContainerNode.swift - submodules/TelegramUI/Sources/ContactMultiselectionController.swift - submodules/TelegramUI/Sources/ContactMultiselectionControllerNode.swift - submodules/TelegramUI/Sources/ContactSelectionController.swift - submodules/TelegramUI/Sources/ContactSelectionControllerNode.swift - -Bridges intentionally retained (out-of-wave scope): - - `canSendMessagesToPeer(peer._asPeer())` — callee takes Peer, deferred - - `peerTokenTitle(peer: peer._asPeer(), ...)` — callee takes Peer, - deferred - -Plan: docs/superpowers/plans/2026-04-24-contactlistpeer-engine-peer-migration.md -Spec: docs/superpowers/specs/2026-04-24-contactlistpeer-engine-peer-migration-design.md - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 12.4: Update CLAUDE.md wave counter** - -Edit `CLAUDE.md` to bump the "Waves landed so far" line from "35 waves" to "36 waves" and update the "as of" date if the commit lands after 2026-04-24. - -- [ ] **Step 12.5: Append wave outcome to the postbox-refactor-log** - -Append a "Wave 36 outcome" section to `docs/superpowers/postbox-refactor-log.md` documenting: -- Actual files touched + edit counts vs. plan -- Any inventory undercounts surfaced by Task 10 (file:line + missed-category) -- Any lessons learned (e.g., whether the γ category really had zero sites; how the φ cast-rewrites behaved; post-migration undercount percentage vs wave 35's 14%) -- Ratio of bridge-drops to bridge-additions (wave theme: removal-dominated) - -Keep concise. - -- [ ] **Step 12.6: Commit the docs update** - -Run: - -```bash -git add CLAUDE.md docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: add wave 36 outcome (ContactListPeer.peer Peer→EnginePeer) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 12.7: Update the next-wave memory** - -Edit `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Add wave 36 to the "Latest commits" section. -- Move ContactListPeer migration from "Recommended wave 36 candidates" to landed. -- Record the inventory undercount ratio (actual-files-touched ÷ pre-flight-file-count) for calibration. -- Update the "Recommended wave 37" section. Promote candidates: `canSendMessagesToPeer(_:)` parameter (the ContactsSearchContainerNode `._asPeer()` bridges at L488/528/562 plus others elsewhere drop when this lands); `peerTokenTitle(peer:)` parameter (drops 3 bridges in ContactMultiselectionController); `makePeerInfoController` / `makeChatQrCodeScreen` / `makeChatRecentActionsController` AccountContext protocol methods (largest remaining Peer-typed-API); accountManager engine path; Shape-C `resourceData` module. - -Use the Edit tool on the memory file. No git commit needed. - ---- - -## Risks and notes - -- **Inventory undercount.** Pre-flight caught several sites the Explore agent missed (inflow wraps at L481/491/517/527/492/844, cast rewrites at L182-186 and L1968). Budget for 1–3 additional misses surfacing in Task 10. If the build surfaces 5+ misses in new categories, stop and reassess. -- **`replace_all` usage.** Every `replace_all=true` Edit in this plan is gated by a pre-flight `grep -c` count check. If the count is wrong, fall back to per-site Edits with surrounding context. -- **Cast rewrite at L182-186.** The original cast chain binds `group` and `channel` (but not `user`). The EnginePeer case-pattern form preserves this: `case .user = peer` is a binding-free match, mirroring `if let _ = peer as? TelegramUser`. -- **`._asPeer()` sites that stay.** Tasks 4.3 and 5.3 explicitly verify that the 3 `canSendMessagesToPeer(peer._asPeer())` bridges and 3 `peerTokenTitle(peer: peer._asPeer(), ...)` bridges remain intact. Dropping these would be out-of-scope migration. -- **WIP isolation.** Pre-existing `ChatListFilterPresetController.swift` / `ChatListFilterPresetListController.swift` edits and untracked `build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/` paths are user WIP — do NOT stage them. Use the explicit `git add ` form in Step 12.2. -- **Scope boundary.** Task 10 errors surfacing in `TelegramCore`, `Postbox`, or `TelegramApi` mean the migration cascaded beyond its intended consumer scope. Halt and investigate — do NOT edit TelegramCore in this wave. -- **No new typealiases/wrappers.** Rule 2 and 3 of the Postbox refactor guidance apply — this wave stays inside. diff --git a/docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md deleted file mode 100644 index f596e3270e..0000000000 --- a/docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md +++ /dev/null @@ -1,1287 +0,0 @@ -# Wave 34: `FoundPeer.peer: Peer → EnginePeer` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the public field `FoundPeer.peer` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum in a single atomic commit. Drops 5 `._asPeer()` bridges (most added in wave 33), eliminates 22 `EnginePeer(peer.peer)` redundant wraps, rewrites 30 Postbox-concrete-type downcasts to enum patterns, and adds bridges where `peer.peer` flows out to APIs that still take raw `Peer`. - -**Architecture:** One atomic commit. The field-type change is necessarily atomic (half-migrated FoundPeer doesn't compile), so all edits land together. TelegramCore's `_internal_searchPeers` keeps `import Postbox` — only `FoundPeer`'s public surface changes. No new wrappers, no new typealiases beyond what already exists. - -**Tech Stack:** Swift, Bazel build via Make.py wrapper. No tests — verification is build success + targeted grep checks. - -**Spec:** `docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md` - ---- - -## File Structure - -**Modified files (8 total — 1 TelegramCore + 7 consumer):** - -| File | Edit count | -|---|---| -| `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` | ~13 spot edits (struct change + 6 filter rewrites + 4 constructor wraps + manual `==` body) | -| `submodules/TelegramCallsUI/Sources/VideoChatScreen.swift` | 1 (bridge-drop at line 1833) | -| `submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift` | 7 (3 C2 downcasts + 4 C3 wraps) | -| `submodules/ContactListUI/Sources/ContactListNode.swift` | ~21 (3 C4 + 13 C2 + 0 C3 + 3 outflow bridges; some lines have multiple edits) | -| `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` | ~17 (8 C2 + ~9 C3) | -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift` | ~10 (2 C4 + 3 C2 + 4 C3 + 1 outflow bridge at line 161 — verify) | -| `submodules/TelegramBaseController/Sources/TelegramBaseController.swift` | 6 (1 C4 + 3 C2 + 2 C3) | -| `submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift` | 3 (1 C4 + 2 C3) | - -Note: line counts above are approximations from spec — execution may surface additional outflow-bridge sites caught by the build pass (Task 10). - -**EnginePeer enum case mapping (used throughout):** - -| Postbox concrete | EnginePeer case | -|---|---| -| `TelegramUser` | `.user(TelegramUser)` | -| `TelegramSecretChat` | `.secretChat(TelegramSecretChat)` | -| `TelegramGroup` | `.legacyGroup(TelegramGroup)` | -| `TelegramChannel` | `.channel(TelegramChannel)` | - ---- - -## Task 1: Edit `SearchPeers.swift` — struct definition + body rewrites - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift` - -This is the foundational change. Without it, none of the consumer edits compile in the right direction. - -- [ ] **Step 1.1: Update the FoundPeer struct field, init parameter, and `==` body** - -Edit: - -```swift -// OLD -public struct FoundPeer: Equatable { - public let peer: Peer - public let subscribers: Int32? - - public init(peer: Peer, subscribers: Int32?) { - self.peer = peer - self.subscribers = subscribers - } - - public static func ==(lhs: FoundPeer, rhs: FoundPeer) -> Bool { - return lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers - } -} -``` - -```swift -// NEW -public struct FoundPeer: Equatable { - public let peer: EnginePeer - public let subscribers: Int32? - - public init(peer: EnginePeer, subscribers: Int32?) { - self.peer = peer - self.subscribers = subscribers - } - - public static func ==(lhs: FoundPeer, rhs: FoundPeer) -> Bool { - return lhs.peer == rhs.peer && lhs.subscribers == rhs.subscribers - } -} -``` - -Use the Edit tool with the OLD block as `old_string` and the NEW block as `new_string`. - -- [ ] **Step 1.2: Wrap raw peer values in the four constructor sites inside `_internal_searchPeers`** - -There are four `FoundPeer(peer: peer, subscribers: …)` calls inside `_internal_searchPeers` at lines 70, 72, 85, 87. Each wraps `peer` (a raw `Peer` from `parsedPeers.get(peerId)`) with `EnginePeer(peer)`. - -Edit (replace_all=false because there are 4 distinct contexts; use enough surrounding context per edit to make each unique): - -For lines 70 and 72 (inside the `myResults` loop): - -```swift -// OLD - if let user = peer as? TelegramUser { - renderedMyPeers.append(FoundPeer(peer: peer, subscribers: user.subscriberCount)) - } else { - renderedMyPeers.append(FoundPeer(peer: peer, subscribers: subscribers[peerId])) - } -``` - -```swift -// NEW - if let user = peer as? TelegramUser { - renderedMyPeers.append(FoundPeer(peer: EnginePeer(peer), subscribers: user.subscriberCount)) - } else { - renderedMyPeers.append(FoundPeer(peer: EnginePeer(peer), subscribers: subscribers[peerId])) - } -``` - -For lines 85 and 87 (inside the `results` loop): - -```swift -// OLD - if let user = peer as? TelegramUser { - renderedPeers.append(FoundPeer(peer: peer, subscribers: user.subscriberCount)) - } else { - renderedPeers.append(FoundPeer(peer: peer, subscribers: subscribers[peerId])) - } -``` - -```swift -// NEW - if let user = peer as? TelegramUser { - renderedPeers.append(FoundPeer(peer: EnginePeer(peer), subscribers: user.subscriberCount)) - } else { - renderedPeers.append(FoundPeer(peer: EnginePeer(peer), subscribers: subscribers[peerId])) - } -``` - -- [ ] **Step 1.3: Rewrite the six scope-filter expressions to enum-pattern form** - -For `.channels` scope (two filter blocks; identical bodies — use one Edit per block, NOT replace_all, since the surrounding `renderedMyPeers =` vs `renderedPeers =` differs): - -```swift -// OLD (renderedMyPeers, lines ~96-102) - case .channels: - renderedMyPeers = renderedMyPeers.filter { item in - if let channel = item.peer as? TelegramChannel, case .broadcast = channel.info { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if let channel = item.peer as? TelegramChannel, case .broadcast = channel.info { - return true - } else { - return false - } - } -``` - -```swift -// NEW - case .channels: - renderedMyPeers = renderedMyPeers.filter { item in - if case let .channel(channel) = item.peer, case .broadcast = channel.info { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if case let .channel(channel) = item.peer, case .broadcast = channel.info { - return true - } else { - return false - } - } -``` - -For `.groups` scope (two filter blocks): - -```swift -// OLD - case .groups: - renderedMyPeers = renderedMyPeers.filter { item in - if let channel = item.peer as? TelegramChannel, case .group = channel.info { - return true - } else if item.peer is TelegramGroup { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if let channel = item.peer as? TelegramChannel, case .group = channel.info { - return true - } else if item.peer is TelegramGroup { - return true - } else { - return false - } - } -``` - -```swift -// NEW - case .groups: - renderedMyPeers = renderedMyPeers.filter { item in - if case let .channel(channel) = item.peer, case .group = channel.info { - return true - } else if case .legacyGroup = item.peer { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if case let .channel(channel) = item.peer, case .group = channel.info { - return true - } else if case .legacyGroup = item.peer { - return true - } else { - return false - } - } -``` - -For `.privateChats` scope: - -```swift -// OLD - case .privateChats: - renderedMyPeers = renderedMyPeers.filter { item in - if item.peer is TelegramUser { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if item.peer is TelegramUser { - return true - } else { - return false - } - } -``` - -```swift -// NEW - case .privateChats: - renderedMyPeers = renderedMyPeers.filter { item in - if case .user = item.peer { - return true - } else { - return false - } - } - renderedPeers = renderedPeers.filter { item in - if case .user = item.peer { - return true - } else { - return false - } - } -``` - -- [ ] **Step 1.4: Verify** — read the updated file from line 1 to ~155 and confirm there are no remaining `item.peer as? Telegram*` or `item.peer is Telegram*` patterns and the four `FoundPeer(peer: peer, ...)` constructions are now `FoundPeer(peer: EnginePeer(peer), ...)`. Do not commit yet. - ---- - -## Task 2: Edit `VideoChatScreen.swift` - -**Files:** -- Modify: `submodules/TelegramCallsUI/Sources/VideoChatScreen.swift` - -- [ ] **Step 2.1: Drop `._asPeer()` bridge in the FoundPeer constructor at line 1833** - -Edit: - -```swift -// OLD - |> map { peer in - return [FoundPeer(peer: peer._asPeer(), subscribers: nil)] - } -``` - -```swift -// NEW - |> map { peer in - return [FoundPeer(peer: peer, subscribers: nil)] - } -``` - -The surrounding context (`|> mapToSignal { peer -> Signal` two lines earlier) confirms `peer` is `EnginePeer`. The `._asPeer()` bridge becomes unnecessary. - ---- - -## Task 3: Edit `VideoChatScreenMoreMenu.swift` - -**Files:** -- Modify: `submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift` - -7 edits in this file: 4 C3 wrap-drops + 3 C2 downcast rewrites. - -- [ ] **Step 3.1: Drop the two `EnginePeer(peer.peer)` wraps on line 171** - -Edit: - -```swift -// OLD - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: currentCall.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in -``` - -```swift -// NEW - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(peer.peer.displayTitle(strings: environment.strings, displayOrder: currentCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: currentCall.accountContext.account, peer: peer.peer, size: avatarSize)), action: { [weak self] c, _ in -``` - -- [ ] **Step 3.2: Rewrite the C2 downcasts at lines 628–648** - -Edit: - -```swift -// OLD (around lines 627-635) - for peer in displayAsPeers { - if peer.peer is TelegramGroup { - isGroup = true - break - } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { - isGroup = true - break - } - } -``` - -```swift -// NEW - for peer in displayAsPeers { - if case .legacyGroup = peer.peer { - isGroup = true - break - } else if case let .channel(channel) = peer.peer, case .group = channel.info { - isGroup = true - break - } - } -``` - -(Note the `else if let peer = peer.peer as? TelegramChannel` shadowed the outer loop `peer` with a new `peer: TelegramChannel`. The rewrite uses `channel` to avoid further shadowing the EnginePeer loop variable.) - -Edit (around line 648): - -```swift -// OLD - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { - subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = environment.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -```swift -// NEW - } else if let subscribers = peer.subscribers { - if case let .channel(channel) = peer.peer, case .broadcast = channel.info { - subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = environment.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -- [ ] **Step 3.3: Drop the `EnginePeer(peer.peer)` wrap at line 658** - -Edit: - -```swift -// OLD - let avatarSignal = peerAvatarCompleteImage(account: groupCall.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize) -``` - -```swift -// NEW - let avatarSignal = peerAvatarCompleteImage(account: groupCall.accountContext.account, peer: peer.peer, size: avatarSize) -``` - -- [ ] **Step 3.4: Drop the `EnginePeer(peer.peer)` wrap at line 679** - -Edit: - -```swift -// OLD - items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in -``` - -```swift -// NEW - items.append(.action(ContextMenuActionItem(text: peer.peer.displayTitle(strings: environment.strings, displayOrder: groupCall.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in -``` - ---- - -## Task 4: Edit `ContactListNode.swift` - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactListNode.swift` - -The largest file: 3 C4 (constructor) edits, 13 C2 (downcast) rewrites, and 3 outflow bridges where `peer.peer` is passed to `.peer(peer:)` (raw-Peer-taking enum case). - -- [ ] **Step 4.1: Bridge-drop at constructor lines 1485 and 1517** - -Edit: - -```swift -// OLD (line 1485) - resultPeers.append(FoundPeer(peer: mainPeer._asPeer(), subscribers: nil)) -``` - -```swift -// NEW - resultPeers.append(FoundPeer(peer: mainPeer, subscribers: nil)) -``` - -(Verification: `mainPeer` is bound from `peer.chatMainPeer` at line 1479. `chatMainPeer` returns `EnginePeer?` on `EngineRenderedPeer`. After migration this needs no bridge.) - -Edit: - -```swift -// OLD (line 1517) - return (peers.map({ FoundPeer(peer: $0._asPeer(), subscribers: nil) }), presences) -``` - -```swift -// NEW - return (peers.map({ FoundPeer(peer: $0, subscribers: nil) }), presences) -``` - -(Verification: `peers` comes from `context.engine.contacts.searchContacts(query:)` whose return type's first element is `[EnginePeer]`. Confirmed by inspection.) - -- [ ] **Step 4.2: Rewrite the C2 downcast at line 1501** - -Edit: - -```swift -// OLD - if let _ = peer.peer as? TelegramChannel { -``` - -```swift -// NEW - if case .channel = peer.peer { -``` - -- [ ] **Step 4.3: Rewrite the three identical C2 sites at lines 1563, 1569, 1574** - -These three lines have the SAME pattern (`if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) {`). Use `replace_all=true` since the line is identical (verify uniqueness by grepping first). - -Edit (`replace_all=true`): - -```swift -// OLD - if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) { -``` - -```swift -// NEW - if case let .user(user) = peer.peer, user.flags.contains(.requirePremium) { -``` - -Verify `replace_all` actually replaced exactly 3 sites (count occurrences before and after; if not 3, something else uses the same pattern and you must split into individual edits). - -- [ ] **Step 4.4: Add `._asPeer()` outflow bridges at lines 1656, 1693, 1731** - -These are `peers.append(.peer(peer: peer.peer, ...))` where `.peer(peer:)` is `ContactListPeer.peer(peer:isGlobal:participantCount:)` taking raw `Peer`. Add bridge. - -Edit (line 1656): - -```swift -// OLD - peers.append(.peer(peer: peer.peer, isGlobal: false, participantCount: peer.subscribers)) -``` - -```swift -// NEW - peers.append(.peer(peer: peer.peer._asPeer(), isGlobal: false, participantCount: peer.subscribers)) -``` - -Edit (line 1693): - -```swift -// OLD - peers.append(.peer(peer: peer.peer, isGlobal: true, participantCount: peer.subscribers)) -``` - -```swift -// NEW - peers.append(.peer(peer: peer.peer._asPeer(), isGlobal: true, participantCount: peer.subscribers)) -``` - -The same pattern appears at line 1731 — apply the same edit. Use `replace_all` if and only if the second occurrence is identical (it is, but verify). - -- [ ] **Step 4.5: Rewrite the C2 sites at lines 1658, 1665, 1673, 1675, 1695, 1703, 1711, 1713, 1733** - -These are scattered through three nearly-identical loop bodies (`localPeersAndStatuses.0`, `remotePeers.0`, `remotePeers.1`). The patterns: - -For lines 1658, 1695, 1733 (`if searchDeviceContacts, let user = peer.peer as? TelegramUser, let phone = user.phone`): - -Edit (`replace_all=true` if the 3 occurrences are textually identical — verify): - -```swift -// OLD - if searchDeviceContacts, - let user = peer.peer as? TelegramUser, - let phone = user.phone { -``` - -```swift -// NEW - if searchDeviceContacts, - case let .user(user) = peer.peer, - let phone = user.phone { -``` - -For line 1665 and 1703 (single-condition `if let user = peer.peer as? TelegramUser {`): - -Edit (`replace_all=true` if textually identical): - -```swift -// OLD - if let user = peer.peer as? TelegramUser { -``` - -```swift -// NEW - if case let .user(user) = peer.peer { -``` - -For line 1673 (`if peer.peer is TelegramGroup && searchGroups`): - -Edit: - -```swift -// OLD - if peer.peer is TelegramGroup && searchGroups { - matches = true - } else if let channel = peer.peer as? TelegramChannel { -``` - -```swift -// NEW - if case .legacyGroup = peer.peer, searchGroups { - matches = true - } else if case let .channel(channel) = peer.peer { -``` - -For line 1711 (`if peer.peer is TelegramGroup`): - -Edit: - -```swift -// OLD - if peer.peer is TelegramGroup { - matches = searchGroups - } else if let channel = peer.peer as? TelegramChannel { -``` - -```swift -// NEW - if case .legacyGroup = peer.peer { - matches = searchGroups - } else if case let .channel(channel) = peer.peer { -``` - -(Note that line 1675 and 1713 carry the same `else if let channel = peer.peer as? TelegramChannel` pattern that is folded into the edits above. Confirm both got rewritten.) - -- [ ] **Step 4.6: Verify** — grep for remaining FoundPeer-relevant Postbox patterns in the file: - -Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)|FoundPeer\(peer:\s+\w+\._asPeer\(\)" submodules/ContactListUI/Sources/ContactListNode.swift` - -Expected: zero matches if the C2/C3/C4 edits are complete. - ---- - -## Task 5: Edit `ChatListSearchListPaneNode.swift` - -**Files:** -- Modify: `submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` - -8 C2 downcasts + 9 C3 wrap drops. - -- [ ] **Step 5.1a: Add outflow bridge at line 1018** - -Edit: - -```swift -// OLD - enabled = canSendMessagesToPeer(peer.peer) -``` - -```swift -// NEW - enabled = canSendMessagesToPeer(peer.peer._asPeer()) -``` - -(`canSendMessagesToPeer(_ peer: Peer, ...)` in `submodules/TelegramCore/Sources/Utils/CanSendMessagesToPeer.swift` takes raw `Peer`, so the bridge is required.) - -- [ ] **Step 5.1b: Rewrite the disjunction at line 1024 using `switch`** - -Edit: - -```swift -// OLD - if filter.contains(.onlyPrivateChats) { - if !(peer.peer is TelegramUser || peer.peer is TelegramSecretChat) { - enabled = false - } - } -``` - -```swift -// NEW - if filter.contains(.onlyPrivateChats) { - switch peer.peer { - case .user, .secretChat: - break - default: - enabled = false - } - } -``` - -The `switch ... case .user, .secretChat: break / default: enabled = false` form preserves the negation semantics of the original `if !(... || ...)` and reads cleanly. - -- [ ] **Step 5.1c: Rewrite C2 downcasts at lines 1029-1030** - -Edit (lines 1029-1030): - -```swift -// OLD - if let _ = peer.peer as? TelegramGroup { - } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { -``` - -```swift -// NEW - if case .legacyGroup = peer.peer { - } else if case let .channel(channel) = peer.peer, case .group = channel.info { -``` - -Edit (lines 1038-1040): - -```swift -// OLD - if peer.peer is TelegramUser { -``` - -(continues `} else if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info {` at line 1040) - -```swift -// NEW - if case .user = peer.peer { -``` - -```swift -// OLD - } else if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info { -``` - -```swift -// NEW - } else if case let .channel(channel) = peer.peer, case .broadcast = channel.info { -``` - -(If line 1040's old form is used elsewhere in the file, scope the Edit by including more surrounding context.) - -- [ ] **Step 5.2: Drop C3 wraps on line 1075** - -Edit: - -```swift -// OLD - return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .generalSearch(isSavedMessages: isSavedMessages), peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)), status: .addressName(suffixString), badge: badge, requiresPremiumForMessaging: requiresPremiumForMessaging, enabled: enabled, selection: .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: header, searchQuery: query, isAd: false, action: { _ in -``` - -```swift -// NEW - return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: .generalSearch(isSavedMessages: isSavedMessages), peer: .peer(peer: peer.peer, chatPeer: peer.peer), status: .addressName(suffixString), badge: badge, requiresPremiumForMessaging: requiresPremiumForMessaging, enabled: enabled, selection: .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: header, searchQuery: query, isAd: false, action: { _ in -``` - -- [ ] **Step 5.3: Drop C3 wraps on lines 1076, 1078, 1081** - -Edit (line 1076): - -```swift -// OLD - interaction.peerSelected(EnginePeer(peer.peer), nil, nil, nil, false) -``` - -```swift -// NEW - interaction.peerSelected(peer.peer, nil, nil, nil, false) -``` - -Edit (line 1078): - -```swift -// OLD - interaction.disabledPeerSelected(EnginePeer(peer.peer), nil, requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -```swift -// NEW - interaction.disabledPeerSelected(peer.peer, nil, requiresPremiumForMessaging ? .premiumRequired : .generic) -``` - -Edit (line 1081): - -```swift -// OLD - peerContextAction(EnginePeer(peer.peer), .search(nil), node, gesture, location) -``` - -```swift -// NEW - peerContextAction(peer.peer, .search(nil), node, gesture, location) -``` - -- [ ] **Step 5.4: Rewrite the C2 downcasts at lines 1500 and 1507 (inside `filteredPeerSearchQueryResults`)** - -Edit (line 1500, inside `value.0.filter`): - -```swift -// OLD - value.0.filter { peer in - if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info { - return true - } else { - return false - } - }, -``` - -```swift -// NEW - value.0.filter { peer in - if case let .channel(channel) = peer.peer, case .broadcast = channel.info { - return true - } else { - return false - } - }, -``` - -Edit (line 1507, inside `value.1.filter`): - -```swift -// OLD - value.1.filter { peer in - if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info { - return true - } else { - return false - } - } -``` - -```swift -// NEW - value.1.filter { peer in - if case let .channel(channel) = peer.peer, case .broadcast = channel.info { - return true - } else { - return false - } - } -``` - -- [ ] **Step 5.5: Drop C3 wraps in `foundRemotePeers` loops (lines 3088, 3096, 3214, 3216, 3241)** - -Edit (`replace_all=true` for the lines that match exactly the same pattern, otherwise individual Edits): - -```swift -// OLD (occurs at 3088, 3096, 3214, 3241 — the FoundPeer wrap; the EnginePeer(accountPeer) wrap stays since `accountPeer` is raw Peer) - if !existingPeerIds.contains(peer.peer.id), filteredPeer(EnginePeer(peer.peer), EnginePeer(accountPeer)) { -``` - -```swift -// NEW - if !existingPeerIds.contains(peer.peer.id), filteredPeer(peer.peer, EnginePeer(accountPeer)) { -``` - -If `replace_all=true`, verify the count is exactly 4 by grep before and after. - -Edit (line 3216 separately — different pattern): - -```swift -// OLD - entries.append(.localPeer(EnginePeer(peer.peer), nil, nil, index, presentationData.theme, presentationData.strings, presentationData.nameSortOrder, presentationData.nameDisplayOrder, localExpandType, nil, false, false)) -``` - -```swift -// NEW - entries.append(.localPeer(peer.peer, nil, nil, index, presentationData.theme, presentationData.strings, presentationData.nameSortOrder, presentationData.nameDisplayOrder, localExpandType, nil, false, false)) -``` - -(Note: this assumes `.localPeer(EnginePeer, ...)` accepts EnginePeer directly. If the build fails saying it expected raw `Peer`, add `._asPeer()` instead. Verify in build pass.) - -- [ ] **Step 5.6: Verify** — grep: - -Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)" submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift` - -Expected: zero matches. - ---- - -## Task 6: Edit `PeerInfoScreenCallActions.swift` - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift` - -2 C4 bridge-drops + 3 C2 downcasts + 4 C3 wraps. The function is duplicated almost verbatim at the second site (line 156 vs 265). - -- [ ] **Step 6.1: Bridge-drops at lines 156 and 265** - -Edit (`replace_all=true` if the line is literally identical at both sites): - -```swift -// OLD - return [FoundPeer(peer: peer._asPeer(), subscribers: nil)] -``` - -```swift -// NEW - return [FoundPeer(peer: peer, subscribers: nil)] -``` - -Verify `replace_all` got exactly 2 sites. - -- [ ] **Step 6.2: Rewrite C2 downcasts at lines 175, 178, 193** - -Edit (lines 175 and 178 form one if-else chain): - -```swift -// OLD - for peer in peers { - if peer.peer is TelegramGroup { - isGroup = true - break - } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { - isGroup = true - break - } - } -``` - -```swift -// NEW - for peer in peers { - if case .legacyGroup = peer.peer { - isGroup = true - break - } else if case let .channel(channel) = peer.peer, case .group = channel.info { - isGroup = true - break - } - } -``` - -Edit (line 193 — inside the second `for peer in peers` loop): - -```swift -// OLD - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { - subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -```swift -// NEW - } else if let subscribers = peer.subscribers { - if case let .channel(channel) = peer.peer, case .broadcast = channel.info { - subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -- [ ] **Step 6.3: Drop C3 wraps at lines 201, 202** - -Edit: - -```swift -// OLD - let avatarSignal = peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize) - items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: avatarSignal), action: { _, f in -``` - -```swift -// NEW - let avatarSignal = peerAvatarCompleteImage(account: strongSelf.context.account, peer: peer.peer, size: avatarSize) - items.append(.action(ContextMenuActionItem(text: peer.peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: avatarSignal), action: { _, f in -``` - -- [ ] **Step 6.4: Drop two C3 wraps on line 288** - -Edit: - -```swift -// OLD - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { c, f in -``` - -```swift -// NEW - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(peer.peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: strongSelf.context.account, peer: peer.peer, size: avatarSize)), action: { c, f in -``` - -- [ ] **Step 6.5: Verify** — grep: - -Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift` - -Expected: zero matches. - ---- - -## Task 7: Edit `TelegramBaseController.swift` - -**Files:** -- Modify: `submodules/TelegramBaseController/Sources/TelegramBaseController.swift` - -1 C4 bridge-drop + 3 C2 downcasts + 2 C3 wraps. - -- [ ] **Step 7.1: Bridge-drop at line 208** - -Edit: - -```swift -// OLD - |> map { peer in - return [FoundPeer(peer: peer._asPeer(), subscribers: nil)] - } -``` - -```swift -// NEW - |> map { peer in - return [FoundPeer(peer: peer, subscribers: nil)] - } -``` - -- [ ] **Step 7.2: Rewrite C2 downcasts at lines 243, 246, 258** - -Edit (lines 243 and 246 form one if-else chain): - -```swift -// OLD - for peer in peers { - if peer.peer is TelegramGroup { - isGroup = true - break - } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { - isGroup = true - break - } - } -``` - -```swift -// NEW - for peer in peers { - if case .legacyGroup = peer.peer { - isGroup = true - break - } else if case let .channel(channel) = peer.peer, case .group = channel.info { - isGroup = true - break - } - } -``` - -Edit (line 258): - -```swift -// OLD - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { - subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -```swift -// NEW - } else if let subscribers = peer.subscribers { - if case let .channel(channel) = peer.peer, case .broadcast = channel.info { - subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) - } - } -``` - -- [ ] **Step 7.3: Drop two C3 wraps on line 265** - -Edit: - -```swift -// OLD - items.append(VoiceChatPeerActionSheetItem(context: context, peer: EnginePeer(peer.peer), title: EnginePeer(peer.peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), subtitle: subtitle ?? "", action: { -``` - -```swift -// NEW - items.append(VoiceChatPeerActionSheetItem(context: context, peer: peer.peer, title: peer.peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), subtitle: subtitle ?? "", action: { -``` - -- [ ] **Step 7.4: Verify** — grep: - -Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)" submodules/TelegramBaseController/Sources/TelegramBaseController.swift` - -Expected: zero matches. - ---- - -## Task 8: Edit `StorageUsageExceptionsScreen.swift` - -**Files:** -- Modify: `submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift` - -1 C4 wrap-needed + 2 C3 wrap-drops. - -- [ ] **Step 8.1: Drop C3 wrap at line 173** - -Edit: - -```swift -// OLD - title = EnginePeer(peer.peer).displayTitle(strings: presentationData.strings, displayOrder: .firstLast) -``` - -```swift -// NEW - title = peer.peer.displayTitle(strings: presentationData.strings, displayOrder: .firstLast) -``` - -- [ ] **Step 8.2: Drop C3 wrap at line 176** - -Edit: - -```swift -// OLD - return ItemListDisclosureItem(presentationData: presentationData, icon: nil, context: arguments.context, iconPeer: EnginePeer(peer.peer), title: title, enabled: true, titleFont: .bold, label: optionText, labelStyle: .text, additionalDetailLabel: additionalDetailLabel, sectionId: self.section, style: .blocks, disclosureStyle: .optionArrows, action: { -``` - -```swift -// NEW - return ItemListDisclosureItem(presentationData: presentationData, icon: nil, context: arguments.context, iconPeer: peer.peer, title: title, enabled: true, titleFont: .bold, label: optionText, labelStyle: .text, additionalDetailLabel: additionalDetailLabel, sectionId: self.section, style: .blocks, disclosureStyle: .optionArrows, action: { -``` - -- [ ] **Step 8.3: Add EnginePeer wrap at constructor line 288** - -Edit: - -```swift -// OLD - result.append((peer: FoundPeer(peer: peer, subscribers: subscriberCount), value: value)) -``` - -```swift -// NEW - result.append((peer: FoundPeer(peer: EnginePeer(peer), subscribers: subscriberCount), value: value)) -``` - -(Verification: `peer` here is bound from a Postbox transaction higher up — it is a raw `Peer`. The wrap is required.) - -- [ ] **Step 8.4: Verify** — grep: - -Run: `grep -nE "EnginePeer\(peer\.peer\)" "submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift"` - -Expected: zero matches. - ---- - -## Task 9: Build verification (first pass) - -- [ ] **Step 9.1: Run the full build with `--continueOnError`** - -Run: - -```bash -source ~/.zshrc 2>/dev/null && python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError 2>&1 | tee /tmp/wave34-build.log -``` - -Expected outcome: ideally clean. Realistic outcome: a small number of errors at sites the inventory missed. - -- [ ] **Step 9.2: Triage build errors** - -Likely error patterns and their fixes: - -| Error | Fix | -|---|---| -| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer'` at site `(peer.peer, ...)` | Add `._asPeer()` bridge: `(peer.peer._asPeer(), ...)` | -| `value of type 'EnginePeer' has no member 'isEqual'` | Replace with `==`: `peer.peer == otherPeer` | -| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer?'` | Same as above: `._asPeer()` bridge | -| `pattern of type 'TelegramX' cannot match values of type 'EnginePeer'` | Missed C2 site — rewrite to `if case .X = peer.peer` form | -| `extraneous argument 'EnginePeer'` (when `.localPeer` etc. expected raw Peer) | Add `._asPeer()` bridge | - -For each error, identify the file:line, apply the appropriate fix, and re-run the build (Step 9.1) until clean. - -- [ ] **Step 9.3: Iterate to clean build** - -Re-run the build after each batch of fixes. The wave is complete when the build returns 0 errors. - -If 10+ unexpected errors surface, halt and reassess: the inventory was significantly incomplete and the wave may need to be split into pre-cleanup commits. Discuss with user. - ---- - -## Task 10: Post-build grep validations - -- [ ] **Step 10.1: Bridge-drop validation** - -Run: - -```bash -grep -rn "FoundPeer(peer:.*\._asPeer()" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/" | grep -v "^submodules/Postbox/" -``` - -Expected: zero hits. If any remain, they are missed bridge-drops — fix and re-build. - -- [ ] **Step 10.2: C3 wrap validation** - -Run for each touched consumer file: - -```bash -for f in submodules/TelegramCallsUI/Sources/VideoChatScreen.swift \ - submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift \ - submodules/ContactListUI/Sources/ContactListNode.swift \ - submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift \ - submodules/TelegramBaseController/Sources/TelegramBaseController.swift \ - "submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift"; do - echo "=== $f ===" - grep -n "EnginePeer(peer\.peer)" "$f" -done -``` - -Expected: zero hits in each touched file. - -- [ ] **Step 10.3: C2 downcast validation** - -Run: - -```bash -for f in submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift \ - submodules/ContactListUI/Sources/ContactListNode.swift \ - submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift \ - submodules/TelegramBaseController/Sources/TelegramBaseController.swift; do - echo "=== $f ===" - grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" "$f" -done -``` - -Expected: zero hits. - -If any of the validations fail, return to Task 9 to fix. - ---- - -## Task 11: Single atomic commit + memory + log update - -- [ ] **Step 11.1: Stage and review** - -Run: - -```bash -git status --short -git diff --stat -``` - -Confirm exactly 8 modified files (1 TelegramCore + 7 consumer) and no other unintended changes. WIP from earlier (`build-system/bazel-rules/sourcekit-bazel-bsp`, `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, untracked `build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/`) should NOT be staged. - -- [ ] **Step 11.2: Stage only the wave-34 files** - -Run: - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift \ - submodules/TelegramCallsUI/Sources/VideoChatScreen.swift \ - submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift \ - submodules/ContactListUI/Sources/ContactListNode.swift \ - submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift \ - submodules/TelegramBaseController/Sources/TelegramBaseController.swift \ - "submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift" -``` - -- [ ] **Step 11.3: Commit** - -Run: - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 34: FoundPeer.peer Peer -> EnginePeer - -Migrates the public field `FoundPeer.peer` from the Postbox `Peer` protocol -to the TelegramCore `EnginePeer` enum. The `_internal_searchPeers` body -keeps `import Postbox` (it still calls `postbox.transaction`) and wraps raw -peer values with `EnginePeer(peer)` at the FoundPeer constructor sites. - -Consumer-side cascade in 7 files: - - 5 `._asPeer()` bridge-drops at FoundPeer constructor sites - - 22 redundant `EnginePeer(peer.peer)` wrap drops (the field is now - EnginePeer, so the wrap fails to compile) - - 30 `peer.peer as? TelegramX` / `is TelegramX` Postbox-concrete - downcasts rewritten to `if case .X = peer.peer` enum-pattern form - - 3 `._asPeer()` bridges added where `peer.peer` flows into - `ContactListPeer.peer(peer:)` (downstream API still takes raw Peer) - - Manual `==` body updated from `lhs.peer.isEqual(rhs.peer)` to - `lhs.peer == rhs.peer` (EnginePeer is Equatable) - -Files modified: - submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift - submodules/TelegramCallsUI/Sources/VideoChatScreen.swift - submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift - submodules/ContactListUI/Sources/ContactListNode.swift - submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swift - submodules/TelegramBaseController/Sources/TelegramBaseController.swift - submodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift - -Plan: docs/superpowers/plans/2026-04-24-foundpeer-engine-peer-migration.md -Spec: docs/superpowers/specs/2026-04-24-foundpeer-engine-peer-migration-design.md - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 11.4: Update CLAUDE.md wave counter** - -Edit `CLAUDE.md` to bump the "Waves landed so far" line from "33 waves" to "34 waves" and update the "as of" date if changed. - -- [ ] **Step 11.5: Append wave outcome to the postbox-refactor-log** - -Append a new "Wave 34 outcome" section to `docs/superpowers/postbox-refactor-log.md` documenting the migration, the actual edit count (in case it differed from the planned ~70), and any lessons learned. Keep concise. - -- [ ] **Step 11.6: Commit the docs update** - -Run: - -```bash -git add CLAUDE.md docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: add wave 34 outcome (FoundPeer.peer Peer→EnginePeer) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 11.7: Update the next-wave memory** - -Update `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Add wave 34 to the "Latest commits" section -- Move FoundPeer migration from "Wave 34+ candidates" to landed -- Reframe remaining work: SendAsPeer, makePeerInfoController, etc., are now the next ring of Peer-typed-API waves -- Note any newly-surfaced bridge-add sites (the 3 `._asPeer()` bridges in ContactListNode point to `ContactListPeer.peer(peer:)` as the next downstream-API migration target) - -Use the Edit tool on the memory file. No git commit needed for the memory file (it's outside the repo). - ---- - -## Risks and notes - -- **Inner `peer` shadowing.** Several rewrites collapse `else if let peer = peer.peer as? TelegramChannel` patterns where the inner `peer` shadows the loop variable. The rewrites use `channel` as the new binding name to avoid double shadowing of the EnginePeer loop variable. Confirm subsequent uses of the bound name within the if-let scope are updated (e.g., `peer.info` becomes `channel.info`). -- **`replace_all` correctness.** Whenever the plan suggests `replace_all=true`, verify the count first via grep. If the count is unexpected, revert to per-site Edits with surrounding context. -- **Outflow-bridge surprises.** The plan enumerates 3 `._asPeer()` outflow bridges in ContactListNode. The build pass (Task 9) may surface 1–3 more in other touched files (e.g., ChatListSearchListPaneNode's `.localPeer` case). Apply the bridge pattern to each and iterate. -- **WIP isolation.** Pre-existing modifications to `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, the `sourcekit-bazel-bsp` submodule marker, and untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/` are user WIP — do NOT stage them. Use the explicit `git add ` form in Step 11.2. diff --git a/docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md deleted file mode 100644 index f22b33ca5e..0000000000 --- a/docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md +++ /dev/null @@ -1,411 +0,0 @@ -# Wave 40 — `makeChatQrCodeScreen` + `makeChatRecentActionsController` Peer → EnginePeer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bundle migrate two sibling `AccountContext` methods deferred from wave 39 — `makeChatQrCodeScreen` (4 consumer sites) and `makeChatRecentActionsController` (3 consumer sites) — from raw `peer: Peer` to `peer: EnginePeer`, applying the body-shadow pattern. - -**Architecture:** Body-shadow pattern (wave-38/39 style). Protocol + impl signatures change to `peer: EnginePeer`; each impl body gets a `let peer = peer._asPeer()` shadow so the downstream constructors (`ChatQrCodeScreenImpl`, `ChatRecentActionsController`) remain raw-`Peer` consumers (out of scope). - -**Tech Stack:** Swift, Bazel, iOS; TelegramCore / AccountContext / TelegramUI / PeerInfoUI / StatisticsUI / SettingsUI / ContactListUI / PeerInfoScreen submodules. - -**Reference:** Wave-39 "Out of scope" section in `docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md`. - ---- - -## Pre-flight classification - -**`makeChatQrCodeScreen` (4 consumer sites):** - -| # | Site | Shape | Edit | -|---|---|---|---| -| 1 | `SettingsSearchableItems.swift:974` | **Shape-A-variant** | Rewrite upstream `guard let peer = peer?._asPeer() else { return }` (line 971) → `guard let peer = peer else { return }`. Call stays `peer: peer`. | -| 2 | `SettingsSearchableItems.swift:992` | **Shape-A-variant** | Same pattern as #1 (upstream guard at line 989). | -| 3 | `ContactsController.swift:478` | **Shape-A** | Drop `._asPeer()` from `peer: peer._asPeer()` → `peer: peer`. Source: `Signal`. | -| 4 | `PeerInfoScreen.swift:4623` | **Shape-C** | Wrap: `peer: peer` → `peer: EnginePeer(peer)`. Source: `data.peer: Peer?`. | - -**`makeChatRecentActionsController` (3 consumer sites):** - -| # | Site | Shape | Edit | -|---|---|---|---| -| 5 | `ChannelAdminsController.swift:734` | **Shape-A** | Drop `._asPeer()`. Source: `engine.data.get(Peer.Peer(id:))` — `peer` is `EnginePeer` in the `guard let peer` on line 729. | -| 6 | `GroupStatsController.swift:915` | **Shape-A** | Drop `._asPeer()`. Source: `Signal` (mapToSignal at 906). | -| 7 | `PeerInfoScreenOpenChat.swift:115` | **Shape-C** | Wrap: `peer: peer` → `peer: EnginePeer(peer)`. Source: `self.data?.peer: Peer?`. | - -**Net bridge delta:** −5 `_asPeer()` drops (sites 1, 2, 3, 5, 6) + 2 `EnginePeer(...)` wraps (sites 4, 7) = **−3 net**. Sites 4 and 7 become ratchet markers for a future `PeerInfoScreenData.peer Peer → EnginePeer` wave. - ---- - -## File touch summary - -8 files: - -1. `submodules/AccountContext/Sources/AccountContext.swift` — protocol decls (2 lines). -2. `submodules/TelegramUI/Sources/SharedAccountContext.swift` — impl signatures + body shadows (2 sites). -3. `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift` — 2 Shape-A-variant upstream guard rewrites. -4. `submodules/ContactListUI/Sources/ContactsController.swift` — 1 Shape-A drop. -5. `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` — 1 Shape-C wrap. -6. `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` — 1 Shape-A drop. -7. `submodules/StatisticsUI/Sources/GroupStatsController.swift` — 1 Shape-A drop. -8. `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift` — 1 Shape-C wrap. - ---- - -### Task 1: Update `AccountContext` protocol signatures - -**Files:** -- Modify: `submodules/AccountContext/Sources/AccountContext.swift:1401` and `:1461` - -- [ ] **Step 1: Update `makeChatRecentActionsController` decl** - -```swift -// old_string - func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController - -// new_string - func makeChatRecentActionsController(context: AccountContext, peer: EnginePeer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController -``` - -- [ ] **Step 2: Update `makeChatQrCodeScreen` decl** - -```swift -// old_string - func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController - -// new_string - func makeChatQrCodeScreen(context: AccountContext, peer: EnginePeer, threadId: Int64?, temporary: Bool) -> ViewController -``` - ---- - -### Task 2: Update `SharedAccountContext` impls with body-shadow - -**Files:** -- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:2302` (makeChatRecentActionsController) -- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:2730` (makeChatQrCodeScreen) - -- [ ] **Step 1: Update `makeChatRecentActionsController` impl** - -```swift -// old_string - public func makeChatRecentActionsController(context: AccountContext, peer: Peer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController { - return ChatRecentActionsController(context: context, peer: peer, adminPeerId: adminPeerId, starsState: starsState) - } - -// new_string - public func makeChatRecentActionsController(context: AccountContext, peer: EnginePeer, adminPeerId: PeerId?, starsState: StarsRevenueStats?) -> ViewController { - let peer = peer._asPeer() - return ChatRecentActionsController(context: context, peer: peer, adminPeerId: adminPeerId, starsState: starsState) - } -``` - -- [ ] **Step 2: Update `makeChatQrCodeScreen` impl** - -```swift -// old_string - public func makeChatQrCodeScreen(context: AccountContext, peer: Peer, threadId: Int64?, temporary: Bool) -> ViewController { - return ChatQrCodeScreenImpl(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary)) - } - -// new_string - public func makeChatQrCodeScreen(context: AccountContext, peer: EnginePeer, threadId: Int64?, temporary: Bool) -> ViewController { - let peer = peer._asPeer() - return ChatQrCodeScreenImpl(context: context, subject: .peer(peer: peer, threadId: threadId, temporary: temporary)) - } -``` - ---- - -### Task 3: `SettingsSearchableItems.swift` — two Shape-A-variant guard rewrites - -**Files:** -- Modify: `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift:971` and `:989` - -Both sites share the same structure: an upstream `guard let peer = peer?._asPeer() else { return }` unwraps `EnginePeer?` to `Peer`. Rewrite the guard to keep the local as `EnginePeer`; the call site below stays unchanged. - -- [ ] **Step 1: Rewrite guard at line 971 (qr-code item)** - -```swift -// old_string - present: { context, _, present in - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer?._asPeer() else { - return - } - let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false) - present(.push, controller) - }) - } - ) - ) - - //TODO:fix - items.append( - SettingsSearchableItem( - id: "qr-code/share", - -// new_string - present: { context, _, present in - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer else { - return - } - let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false) - present(.push, controller) - }) - } - ) - ) - - //TODO:fix - items.append( - SettingsSearchableItem( - id: "qr-code/share", -``` - -- [ ] **Step 2: Rewrite guard at line 989 (qr-code/share item)** - -```swift -// old_string - id: "qr-code/share", - isVisible: false, - present: { context, _, present in - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer?._asPeer() else { - return - } - let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false) - present(.push, controller) - }) - } - -// new_string - id: "qr-code/share", - isVisible: false, - present: { context, _, present in - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer else { - return - } - let controller = context.sharedContext.makeChatQrCodeScreen(context: context, peer: peer, threadId: nil, temporary: false) - present(.push, controller) - }) - } -``` - ---- - -### Task 4: `ContactsController.swift` — Shape-A drop - -**Files:** -- Modify: `submodules/ContactListUI/Sources/ContactsController.swift:478` - -- [ ] **Step 1: Drop `._asPeer()` at the call site** - -```swift -// old_string - controller.present(strongSelf.context.sharedContext.makeChatQrCodeScreen(context: strongSelf.context, peer: peer._asPeer(), threadId: nil, temporary: false), in: .window(.root)) - -// new_string - controller.present(strongSelf.context.sharedContext.makeChatQrCodeScreen(context: strongSelf.context, peer: peer, threadId: nil, temporary: false), in: .window(.root)) -``` - ---- - -### Task 5: `PeerInfoScreen.swift` — Shape-C wrap - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:4623` - -Local `peer` comes from `data.peer` (type `Peer`). Wrap at the call site. - -- [ ] **Step 1: Wrap with `EnginePeer(...)`** - -```swift -// old_string - let qrController = self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: peer, threadId: threadId, temporary: temporary) - -// new_string - let qrController = self.context.sharedContext.makeChatQrCodeScreen(context: self.context, peer: EnginePeer(peer), threadId: threadId, temporary: temporary) -``` - ---- - -### Task 6: `ChannelAdminsController.swift` — Shape-A drop - -**Files:** -- Modify: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift:734` - -Local `peer` is `EnginePeer` (from `guard let peer` on :729 unwrapping `EnginePeer?` from `engine.data.get(Peer.Peer(id:))`). - -- [ ] **Step 1: Drop `._asPeer()`** - -```swift -// old_string - pushControllerImpl?(context.sharedContext.makeChatRecentActionsController(context: context, peer: peer._asPeer(), adminPeerId: nil, starsState: nil)) - -// new_string - pushControllerImpl?(context.sharedContext.makeChatRecentActionsController(context: context, peer: peer, adminPeerId: nil, starsState: nil)) -``` - ---- - -### Task 7: `GroupStatsController.swift` — Shape-A drop - -**Files:** -- Modify: `submodules/StatisticsUI/Sources/GroupStatsController.swift:915` - -Local `peer` is `EnginePeer` (from `Signal` via the `mapToSignal` at :906). - -- [ ] **Step 1: Drop `._asPeer()`** - -```swift -// old_string - let controller = context.sharedContext.makeChatRecentActionsController(context: context, peer: peer._asPeer(), adminPeerId: participantPeerId, starsState: nil) - -// new_string - let controller = context.sharedContext.makeChatRecentActionsController(context: context, peer: peer, adminPeerId: participantPeerId, starsState: nil) -``` - ---- - -### Task 8: `PeerInfoScreenOpenChat.swift` — Shape-C wrap - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift:115` - -Local `peer` comes from `self.data?.peer` (type `Peer`). Wrap at the call site. - -- [ ] **Step 1: Wrap with `EnginePeer(...)`** - -```swift -// old_string - let controller = self.context.sharedContext.makeChatRecentActionsController(context: self.context, peer: peer, adminPeerId: nil, starsState: self.data?.starsRevenueStatsState) - -// new_string - let controller = self.context.sharedContext.makeChatRecentActionsController(context: self.context, peer: EnginePeer(peer), adminPeerId: nil, starsState: self.data?.starsRevenueStatsState) -``` - ---- - -### Task 9: Build + iterate - -- [ ] **Step 1: Run full build** - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -Expected: first-pass-clean (wave 39's 50-file wave landed first-pass-clean; this 8-file wave should too). - -- [ ] **Step 2: If errors, iterate** - -Each error should point at a call site the plan missed. Fix, re-run. Do not widen the scope — if a call site not in the classification table above appears as an error, investigate whether the memory/inventory was stale. - ---- - -### Task 10: Verify no residue - -- [ ] **Step 1: Grep for raw-`Peer` sites** - -```bash -grep -rn "makeChatQrCodeScreen\|makeChatRecentActionsController" --include="*.swift" submodules/ -``` - -Expected output: 2 protocol-decl lines (AccountContext.swift), 2 impl-decl lines (SharedAccountContext.swift), and exactly 7 consumer sites — all with `peer: peer`, `peer: EnginePeer(peer)`, or similar (no `peer: x._asPeer()` remaining for these two methods). - ---- - -### Task 11: Commit + update refactor log - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` — append wave-40 outcome section. - -- [ ] **Step 1: Stage exactly these 8 files (enumerate, do not use `git add -u`)** - -```bash -git add \ - submodules/AccountContext/Sources/AccountContext.swift \ - submodules/TelegramUI/Sources/SharedAccountContext.swift \ - submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift \ - submodules/ContactListUI/Sources/ContactsController.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \ - submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \ - submodules/StatisticsUI/Sources/GroupStatsController.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenChat.swift \ - docs/superpowers/plans/2026-04-24-makeChatQrCodeScreen-recentActions-engine-peer-migration.md -``` - -- [ ] **Step 2: Verify staging with `git status --short`** - -Verify only the 9 files above are staged. If other files appear (e.g. `ChatMessageTransitionNode.swift` WIP, `sourcekit-bazel-bsp` submodule marker) — reset them out of the index with `git restore --staged ` and re-check. - -- [ ] **Step 3: Commit (wave 40)** - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 40 - -makeChatQrCodeScreen + makeChatRecentActionsController peer Peer->EnginePeer. - -- AccountContext protocol: 2 decls updated -- SharedAccountContext impls: 2 signatures + 2 body-shadow `let peer = peer._asPeer()` -- 5 Shape-A `._asPeer()` drops (SettingsSearchableItems x2 guard-variant, ContactsController, ChannelAdminsController, GroupStatsController) -- 2 Shape-C `EnginePeer(peer)` wraps (PeerInfoScreen, PeerInfoScreenOpenChat) -- Net: -3 bridges - -Sibling follow-up to wave 39 (makePeerInfoController). Pre-flight classification -completed in wave-39 design doc's "Out of scope" section. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Log wave 40 outcome** - -Append a new section to `docs/superpowers/postbox-refactor-log.md`: - -```markdown -## Wave 40 outcome - -Commit: ``. Bundle of `AccountContext.makeChatQrCodeScreen` + `makeChatRecentActionsController` peer `Peer → EnginePeer`. 8 files / ~12 lines changed. Pre-flight classification from wave-39 design doc held: 5 Shape-A drops + 2 Shape-C wraps + 2 impl body-shadows + 2 protocol decls. Net −3 bridges. Build outcome: . - -Sibling follow-up to wave 39 — completes the "Option 1 cluster" (makePeerInfoController family from wave-38 memory). Ratchet markers installed at PeerInfoScreen:4623 and PeerInfoScreenOpenChat:115 for a future `PeerInfoScreenData.peer Peer → EnginePeer` wave. -``` - -Then commit the log update: - -```bash -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 40 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 5: Update memory** - -Update `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Move wave-40 (this bundle) from "candidates" to "Latest commits". -- Bump wave-41 recommendation to RenderedChannelParticipant.peer (Option 3) or RenderedPeer (Option 2). -- Add wave-40 lesson if any (e.g. "bundled sibling migration with shared pre-flight is cheap" or similar). - ---- - -## Self-review checklist (writing-plans skill) - -- **Spec coverage:** Every site from the memory/wave-39-doc pre-flight is a task. Sites 1+2 → Task 3; Site 3 → Task 4; Site 4 → Task 5; Site 5 → Task 6; Site 6 → Task 7; Site 7 → Task 8. Impl bodies → Task 2. Protocol → Task 1. Build → Task 9. Verify → Task 10. Commit+log → Task 11. ✓ -- **Placeholders:** None. Every Edit step has exact `old_string` / `new_string`. Commit message and log-update text are spelled out. ✓ -- **Type consistency:** Both methods take `peer: EnginePeer` everywhere — protocol decl, impl decl, and call sites' parameter passes. ✓ diff --git a/docs/superpowers/plans/2026-04-24-makePeerInfoController-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-makePeerInfoController-engine-peer-migration.md deleted file mode 100644 index 16e9a16adb..0000000000 --- a/docs/superpowers/plans/2026-04-24-makePeerInfoController-engine-peer-migration.md +++ /dev/null @@ -1,1037 +0,0 @@ -# Wave 39 — `makePeerInfoController` Peer → EnginePeer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the `peer:` parameter of `AccountContext.makePeerInfoController` from raw `Peer` to `EnginePeer`, dropping 61 `_asPeer()` bridges and adding 12 `EnginePeer(...)` wraps. - -**Architecture:** Body-shadow pattern (wave-38 style). The protocol and impl signatures change to `peer: EnginePeer`. Inside the impl body, a `let peer = peer._asPeer()` shadow preserves the downstream `peerInfoControllerImpl` (private, same file) as a raw-`Peer` consumer — out of scope for this wave. - -**Tech Stack:** Swift, Bazel, iOS; TelegramCore / AccountContext / TelegramUI submodules. - -**Spec:** `docs/superpowers/specs/2026-04-24-makePeerInfoController-engine-peer-migration-design.md` - ---- - -## File touch summary - -- **Signature** (2 files): `submodules/AccountContext/Sources/AccountContext.swift`, `submodules/TelegramUI/Sources/SharedAccountContext.swift`. -- **Shape-A-variant** (1 file): `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift` (3 sites). -- **Shape-C** (8 files, 12 sites): BlockedPeersController, ChannelMembersController, ChannelBlacklistController, ChatRecentActionsControllerNode, PeerInfoScreen, ChatControllerNavigationButtonAction (4), ChatControllerOpenPeer (2), ChatControllerLoadDisplayNode. -- **Shape-A** (~42 files, 58 sites): inline `peer: x._asPeer()` → `peer: x` drops. - -Total: ~50 files. - ---- - -### Task 1: Update `AccountContext` protocol signature - -**Files:** -- Modify: `submodules/AccountContext/Sources/AccountContext.swift:1371` - -- [ ] **Step 1: Apply edit** - -Change the protocol declaration to take `peer: EnginePeer` instead of `peer: Peer`. - -```swift -// old_string - func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: Peer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController? - -// new_string - func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController? -``` - ---- - -### Task 2: Update `SharedAccountContext` implementation + body-shadow + 3 self-call Shape-A drops - -**Files:** -- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:1937` (signature + body) -- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift:3335, 3483, 4016` (Shape-A drops — 3 sites where `self.makePeerInfoController(...)` or `context.sharedContext.makePeerInfoController(...)` is called with `peer: peer._asPeer()`) - -- [ ] **Step 1: Update the impl signature and add body-shadow** - -```swift -// old_string - public func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: Peer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController? { - let controller = peerInfoControllerImpl(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: avatarInitiallyExpanded, isOpenedFromChat: fromChat) - controller?.navigationPresentation = .modalInLargeLayout - return controller - } - -// new_string - public func makePeerInfoController(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)?, peer: EnginePeer, mode: PeerInfoControllerMode, avatarInitiallyExpanded: Bool, fromChat: Bool, requestsContext: PeerInvitationImportersContext?) -> ViewController? { - let peer = peer._asPeer() - let controller = peerInfoControllerImpl(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: avatarInitiallyExpanded, isOpenedFromChat: fromChat) - controller?.navigationPresentation = .modalInLargeLayout - return controller - } -``` - -- [ ] **Step 2: Drop `._asPeer()` at line 3335** (inside `openProfileImpl = { [weak self, weak controller] peer in ...`) - -```swift -// old_string - peer: peer._asPeer(), - mode: .generic, - avatarInitiallyExpanded: peer.smallProfileImage != nil, - fromChat: false, - requestsContext: nil - ) { - controller.replace(with: infoController) - -// new_string - peer: peer, - mode: .generic, - avatarInitiallyExpanded: peer.smallProfileImage != nil, - fromChat: false, - requestsContext: nil - ) { - controller.replace(with: infoController) -``` - -- [ ] **Step 3: Drop `._asPeer()` at line 3483** - -Read 10 lines of context around 3483 to select a unique `old_string` and replace the `peer: peer._asPeer(),` line with `peer: peer,`. The surrounding arguments vary from the line-3335 site, so use a larger surrounding context (6+ lines) to make `old_string` unique. - -- [ ] **Step 4: Drop `._asPeer()` at line 4016** (inside `navigateToPeer: { [weak self] peer in ...` in `makeStarsTransferScreen`) - -```swift -// old_string - peer: peer._asPeer(), - mode: .generic, - avatarInitiallyExpanded: peer.smallProfileImage != nil, - fromChat: false, - requestsContext: nil - ) { - if let navigationController = self.mainWindow?.viewController as? NavigationController { - navigationController.pushViewController(infoController) - -// new_string - peer: peer, - mode: .generic, - avatarInitiallyExpanded: peer.smallProfileImage != nil, - fromChat: false, - requestsContext: nil - ) { - if let navigationController = self.mainWindow?.viewController as? NavigationController { - navigationController.pushViewController(infoController) -``` - ---- - -### Task 3: Shape-A-variant drops in `SettingsSearchableItems.swift` - -**Files:** -- Modify: `submodules/SettingsUI/Sources/Search/SettingsSearchableItems.swift` (lines 1020, 1046, 1080 — the guard statements upstream of the makePeerInfoController calls at 1023, 1049, 1083) - -The call-site line (`peer: peer,`) does not change — the upstream `guard let peer = peer?._asPeer() else` changes to `guard let peer = peer else`, making the local `peer` stay as `EnginePeer` instead of being unwrapped to raw `Peer`. - -- [ ] **Step 1: Line 1020** (inside the `id: "my-profile"` item) - -```swift -// old_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer?._asPeer() else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - -// new_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, -``` - -- [ ] **Step 2: Line 1046** (inside the `id: "my-profile/edit"` item) - -The `old_string` is nearly identical to Step 1. Since Edit tool requires uniqueness, use a broader context including the distinguishing `controller.activateEdit()` suffix (at line ~1062) or use `replace_all=false` with a larger context block. Recommended: include the `Queue.mainQueue().justDispatch { if let controller = controller as? PeerInfoScreen { controller.activateEdit() } }` block below the `present(.push, controller)` line to disambiguate. - -```swift -// old_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer?._asPeer() else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) - present(.push, controller) - - Queue.mainQueue().justDispatch { - if let controller = controller as? PeerInfoScreen { - controller.activateEdit() - } - } - }) - -// new_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfile, - avatarInitiallyExpanded: false, - fromChat: false, - requestsContext: nil - ) - present(.push, controller) - - Queue.mainQueue().justDispatch { - if let controller = controller as? PeerInfoScreen { - controller.activateEdit() - } - } - }) -``` - -- [ ] **Step 3: Line 1080** (inside the `id: "my-profile/gifts"` item — distinguished by `mode: .myProfileGifts`) - -```swift -// old_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer?._asPeer() else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfileGifts, - -// new_string - |> deliverOnMainQueue).start(next: { peer in - guard let peer = peer else { - return - } - let controller = context.sharedContext.makePeerInfoController( - context: context, - updatedPresentationData: nil, - peer: peer, - mode: .myProfileGifts, -``` - ---- - -### Task 4: Shape-C wraps (12 sites across 8 files) - -**Files:** -- Modify: `submodules/SettingsUI/Sources/Privacy and Security/BlockedPeersController.swift:270` -- Modify: `submodules/PeerInfoUI/Sources/ChannelMembersController.swift:707` -- Modify: `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift:381` -- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift:1011` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:4306` -- Modify: `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift:441, 461, 471, 492` -- Modify: `submodules/TelegramUI/Sources/Chat/ChatControllerOpenPeer.swift:218, 359` -- Modify: `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift:4362` - -- [ ] **Step 1: BlockedPeersController.swift:270** - -```swift -// old_string - }, openPeer: { peer in - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - }, openPeer: { peer in - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: EnginePeer(peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 2: ChannelMembersController.swift:707** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: participant.peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - pushControllerImpl?(infoController) - } - } - }, inviteViaLink: { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: EnginePeer(participant.peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - pushControllerImpl?(infoController) - } - } - }, inviteViaLink: { -``` - -- [ ] **Step 3: ChannelBlacklistController.swift:381** - -```swift -// old_string - } else if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: participant.peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - pushControllerImpl?(infoController) - } - })) - -// new_string - } else if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: EnginePeer(participant.peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - pushControllerImpl?(infoController) - } - })) -``` - -- [ ] **Step 4: ChatRecentActionsControllerNode.swift:1011** - -```swift -// old_string - } else { - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - strongSelf.pushController(infoController) - } - } - -// new_string - } else { - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: EnginePeer(peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - strongSelf.pushController(infoController) - } - } -``` - -- [ ] **Step 5: PeerInfoScreen.swift:4306** (inside `openPeerInfo(peer: Peer, isMember: Bool)`) - -```swift -// old_string - private func openPeerInfo(peer: Peer, isMember: Bool) { - let mode: PeerInfoControllerMode = .generic - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: mode, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - private func openPeerInfo(peer: Peer, isMember: Bool) { - let mode: PeerInfoControllerMode = .generic - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: EnginePeer(peer), mode: mode, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 6: ChatControllerNavigationButtonAction.swift:441** - -Context: the outer `if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer` binds `peer` to raw `Peer` (from `RenderedPeer.chatMainPeer` in `TelegramCore/Sources/Utils/PeerUtils.swift:512`). Wrap at the call site. - -```swift -// old_string - if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer, let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { - self.effectiveNavigationController?.pushViewController(infoController) - } - -// new_string - if let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer, let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { - self.effectiveNavigationController?.pushViewController(infoController) - } -``` - -- [ ] **Step 7: ChatControllerNavigationButtonAction.swift:461** - -Context: `peer` here is the outer `peer` bound from `peerView.peers[peerView.peerId]` (raw Peer) at line 418. Wrap at call site. - -```swift -// old_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) { - self.effectiveNavigationController?.pushViewController(infoController) - } - } - - let _ = self.dismissPreviewing?(false) - -// new_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) { - self.effectiveNavigationController?.pushViewController(infoController) - } - } - - let _ = self.dismissPreviewing?(false) -``` - -- [ ] **Step 8: ChatControllerNavigationButtonAction.swift:471** - -Context: `if let peer = self.presentationInterfaceState.renderedPeer?.peer` — raw Peer from Postbox RenderedPeer. Wrap at call site. - -```swift -// old_string - if let peer = self.presentationInterfaceState.renderedPeer?.peer, case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.peerId == self.context.account.peerId { - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: .forumTopic(thread: replyThreadMessage), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { - self.effectiveNavigationController?.pushViewController(infoController) - } - -// new_string - if let peer = self.presentationInterfaceState.renderedPeer?.peer, case let .replyThread(replyThreadMessage) = self.chatLocation, replyThreadMessage.peerId == self.context.account.peerId { - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(peer), mode: .forumTopic(thread: replyThreadMessage), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { - self.effectiveNavigationController?.pushViewController(infoController) - } -``` - -- [ ] **Step 9: ChatControllerNavigationButtonAction.swift:492** - -Context: `channel` is bound from `self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel` — raw `TelegramChannel`/`Peer`. Wrap at call site. - -```swift -// old_string - } else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, case let .replyThread(message) = self.chatLocation { - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: channel, mode: .forumTopic(thread: message), avatarInitiallyExpanded: false, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) { - -// new_string - } else if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isForumOrMonoForum, case let .replyThread(message) = self.chatLocation { - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: EnginePeer(channel), mode: .forumTopic(thread: message), avatarInitiallyExpanded: false, fromChat: true, requestsContext: self.contentData?.inviteRequestsContext) { -``` - -- [ ] **Step 10: ChatControllerOpenPeer.swift:218** - -Context: `peer` is raw Peer from the outer closure parameter. - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer, mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: false, requestsContext: nil) { - strongSelf.effectiveNavigationController?.pushViewController(infoController) - } - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: EnginePeer(peer), mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: false, requestsContext: nil) { - strongSelf.effectiveNavigationController?.pushViewController(infoController) - } -``` - -- [ ] **Step 11: ChatControllerOpenPeer.swift:359** - -Context: `let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer` — raw Peer. - -```swift -// old_string - guard let self, let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer else { - return - } - - guard let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let self, let peer = self.presentationInterfaceState.renderedPeer?.chatMainPeer else { - return - } - - guard let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: EnginePeer(peer), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 12: ChatControllerLoadDisplayNode.swift:4362** - -Context: `let peer = self.presentationInterfaceState.renderedPeer?.peer` — raw Peer. - -```swift -// old_string - guard let self, let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } - if let controller = self.context.sharedContext.makePeerInfoController( - context: self.context, - updatedPresentationData: nil, - peer: peer, - mode: .gifts, - -// new_string - guard let self, let peer = self.presentationInterfaceState.renderedPeer?.peer else { - return - } - if let controller = self.context.sharedContext.makePeerInfoController( - context: self.context, - updatedPresentationData: nil, - peer: EnginePeer(peer), - mode: .gifts, -``` - ---- - -### Task 5: Shape-A drops across 42 files (55 remaining sites) - -Each Shape-A site swaps `peer: ._asPeer()` for `peer: ` at the listed line. Edits are mechanical; bundle into a single implementer dispatch if using subagent-driven development (wave-38 lesson). - -**Files with single sites (replace `peer: ._asPeer(),` or `peer: ._asPeer()` with the `_asPeer()` dropped):** - -Most sites have the pattern `peer: peer._asPeer()`. Use the full single-line `makePeerInfoController(...)` call as `old_string` when replacing to guarantee uniqueness. Two sites use different receivers: - -- `submodules/TelegramUI/Components/PeerInfo/AffiliateProgramSetupScreen/Sources/JoinAffiliateProgramScreen.swift:878` uses `peer: component.sourcePeer._asPeer(),` — drop to `peer: component.sourcePeer,`. -- `submodules/TelegramUI/Sources/Chat/ChatControllerNavigationButtonAction.swift:486` uses `peer: peer._asPeer()` where `peer` is `EnginePeer` (local name shadowed earlier inside `Task {}`). Drop to `peer: peer,`. - -- [ ] **Step 1: SelectivePrivacySettingsPeersController.swift:509** - -```swift -// old_string - guard let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 2: InstantPageControllerNode.swift:1766** - -```swift -// old_string - if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 3: CallListController.swift:375** - -```swift -// old_string - if let strongSelf = self, let peer = peer, let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .calls(messages: messages.map({ $0._asMessage() })), avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let strongSelf = self, let peer = peer, let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .calls(messages: messages.map({ $0._asMessage() })), avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 4: ContactsController.swift:777** - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 5: ContactContextMenus.swift:42** - -```swift -// old_string - guard let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 6: SecureIdAuthController.swift:343** - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 7: ChannelAdminController.swift:1233** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: updatedPresentationData, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 8: ChannelMembersController.swift:785** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 9: ChannelBannedMemberController.swift:785** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: updatedPresentationData, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: updatedPresentationData, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 10: ChannelPermissionsController.swift:1111** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 11: GroupStatsController.swift:883** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 12: MessageStatsController.swift:604** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 13: InviteRequestsController.swift:379** - -```swift -// old_string - if let navigationController = controller?.navigationController as? NavigationController, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { - -// new_string - if let navigationController = controller?.navigationController as? NavigationController, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 14: BrowserInstantPageContent.swift:1501** - -```swift -// old_string - if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 15: WebAppController.swift:3375** - -```swift -// old_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 16: PeersNearbyController.swift:629** - -```swift -// old_string - if let navigationController = controller?.navigationController as? NavigationController, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .nearbyPeer(distance: distance), avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { - -// new_string - if let navigationController = controller?.navigationController as? NavigationController, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .nearbyPeer(distance: distance), avatarInitiallyExpanded: peer.largeProfileImage != nil, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 17: ChatSendStarsScreen.swift:2296** (multi-line call; edit the `peer: peer._asPeer(),` line only; include surrounding lines for uniqueness) - -Read the 5-line context around line 2296, then edit: - -```swift -// old_string (substring) - peer: peer._asPeer(), - -// new_string - peer: peer, -``` - -For uniqueness, include at least one neighboring argument line (e.g., the `mode:` line above or below) in the `old_string`. If truly unique (no other `peer: peer._asPeer(),` lines in this file), `replace_all=false` with this substring works. - -- [ ] **Step 18: ChatRecentActionsControllerNode.swift:1031** - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 19: MiniAppListScreen.swift:230** - -```swift -// old_string - if let peerInfoScreen = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let peerInfoScreen = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 20: JoinSubjectScreen.swift:320** (multi-line) - -Same pattern as Step 17. The `peer: peer._asPeer(),` line at 320 is the only such substring in the file; include the surrounding `mode:` line for safety. - -- [ ] **Step 21: NewContactScreen.swift:586** - -```swift -// old_string - if let infoController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 22: StarsTransactionScreen.swift:1958** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 23: StoryItemSetContainerComponent.swift:5629** - -```swift -// old_string - guard let chatController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let chatController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 24: StoryItemSetContainerComponent.swift:7619** (multi-line; same pattern as Step 17) - -- [ ] **Step 25: StoryItemSetContainerViewSendMessage.swift:3132** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 26: StoryItemSetContainerViewSendMessage.swift:3387** - -```swift -// old_string - if let infoController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = component.context.sharedContext.makePeerInfoController(context: component.context, updatedPresentationData: nil, peer: peer, mode: mode, avatarInitiallyExpanded: expandAvatar, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 27: GiftViewScreen.swift:413, 561, 2252** (three multi-line sites in the same file; use `replace_all=false` with per-site surrounding context for uniqueness) - -Each site has `peer: peer._asPeer(),`. Read 4-line contexts around each line number to construct unique `old_string` blocks. Replace `peer: peer._asPeer(),` with `peer: peer,` in each. - -- [ ] **Step 28: GiftOptionsScreen.swift:930** (multi-line; same pattern as Step 17) - -- [ ] **Step 29: StorageUsageScreen.swift:2078** (multi-line; same pattern as Step 17) - -- [ ] **Step 30: TextProcessingScreen.swift:795** - -```swift -// old_string - if let peerInfoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let peerInfoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 31: PeerInfoScreen.swift:6915, 7218** (two sites — the `old_string`s look identical; use `replace_all=true` or larger per-site context) - -Both lines are identical: `if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) {`. If identical in full, `replace_all=true` with this `old_string` handles both. Verify by reading the two surrounding blocks first — if surrounding context differs, use two focused Edit calls instead. - -```swift -// old_string (both occurrences) - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 32: PeerInfoScreenOpenURL.swift:28** - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 33: JoinAffiliateProgramScreen.swift:878** (multi-line; `peer: component.sourcePeer._asPeer(),` → `peer: component.sourcePeer,`) - -- [ ] **Step 34: ChatControllerScrollToPointInHistory.swift:162** (multi-line; `peer: peer._asPeer(),` → `peer: peer,`) - -- [ ] **Step 35: OpenUrl.swift:175** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 36: OpenUrl.swift:535** (multi-line; `peer: peer._asPeer(),` → `peer: peer,`) - -- [ ] **Step 37: OpenResolvedUrl.swift:1810, 1831** (two multi-line sites; `peer: peer._asPeer(),` → `peer: peer,`; use per-site surrounding context for uniqueness) - -- [ ] **Step 38: TextLinkHandling.swift:45** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 39: ChatController.swift:9437** - -```swift -// old_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: strongSelf.updatedPresentationData, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 40: ChatManagingBotTitlePanelNode.swift:356** - -```swift -// old_string - if let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 41: NavigateToChatController.swift:143** - -```swift -// old_string - if let controller = params.context.sharedContext.makePeerInfoController(context: params.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = params.context.sharedContext.makePeerInfoController(context: params.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 42: OverlayAudioPlayerControllerNode.swift:739** (multi-line) - -- [ ] **Step 43: OpenAddContact.swift:28** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 44: PollResultsController.swift:451** - -```swift -// old_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 45: ChatControllerOpenWebApp.swift:485** - -```swift -// old_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let infoController = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 46: ChatControllerNavigationButtonAction.swift:486** - -Context: inside `Task { @MainActor [weak self] in ...` — local `peer` was bound from `context.engine.data.get(...).get()` (returns `EnginePeer?`), and `_asPeer()` is called to pass to the current Peer-typed API. - -```swift -// old_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer._asPeer(), mode: .monoforum(monoforumPeer.id), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { - -// new_string - if let infoController = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: self.updatedPresentationData, peer: peer, mode: .monoforum(monoforumPeer.id), avatarInitiallyExpanded: false, fromChat: true, requestsContext: nil) { -``` - -- [ ] **Step 47: ChatListController.swift:1913** - -```swift -// old_string - if let peerInfoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let peerInfoController = strongSelf.context.sharedContext.makePeerInfoController(context: strongSelf.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -- [ ] **Step 48: ChatListController.swift:3309** - -```swift -// old_string - guard let peer = peer, let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let peer = peer, let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 49: ChatListController.swift:3795** - -```swift -// old_string - guard let sourceController = sourceController, let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let sourceController = sourceController, let peer = peer, let controller = context.sharedContext.makePeerInfoController(context: context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 50: ChatListController.swift:7301** - -```swift -// old_string - guard let self, let peer = peer, let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { - -// new_string - guard let self, let peer = peer, let controller = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) else { -``` - -- [ ] **Step 51: ChatListSearchListPaneNode.swift:4464** - -```swift -// old_string - if let peerInfoScreen = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer._asPeer(), mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { - -// new_string - if let peerInfoScreen = self.context.sharedContext.makePeerInfoController(context: self.context, updatedPresentationData: nil, peer: peer, mode: .generic, avatarInitiallyExpanded: false, fromChat: false, requestsContext: nil) { -``` - -**Site count check:** 51 steps listed. Multi-line sites (Steps 17, 20, 24, 27, 28, 29, 33, 34, 36, 37, 42) contain 1–3 sub-sites each; Step 27 covers 3 GiftViewScreen sites, Step 31 covers 2 PeerInfoScreen sites, Step 37 covers 2 OpenResolvedUrl sites. Running total: 51 + 2 (step 27 extras) + 1 (step 31 extra) + 1 (step 37 extra) = 55 sites. Adding 3 self-call sites in Task 2 = 58 Shape-A total. Correct. - ---- - -### Task 6: Full project build verification - -- [ ] **Step 1: Run build with `--continueOnError`** - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. If errors, collect the error output for Task 7. - -Budget: 2–4 iterations. Expected runtime: 200–600 seconds per full build. - ---- - -### Task 7: Fix iteration-surfaced errors (if any) - -- [ ] **Step 1: Classify errors** - -Common expected categories: -- **Stored-field type mismatches:** a call site passes `peer` declared as raw `Peer` where the migrated API now wants `EnginePeer`. Fix with `EnginePeer(peer)` wrap at the call site (new Shape-C). -- **Closure-parameter type mismatches:** an outer closure types `peer` as `Peer`, fixed by wrapping at the call site with `EnginePeer(peer)`. -- **Downstream inference cascades:** transient errors in Peer-typed helpers not directly calling `makePeerInfoController`. Verify the helper is not `peerInfoControllerImpl` — if it is, investigate whether the body-shadow boundary was violated (abandonment criterion). - -- [ ] **Step 2: Apply fixes** - -For each unique error site, apply the minimum wrap/drop/shadow change. Do not edit `peerInfoControllerImpl` or any file outside the consumer set. If an error surfaces in `TelegramCore`/`Postbox`/`TelegramApi`, abandon. - -- [ ] **Step 3: Rerun the build** - -Loop back to Task 6 Step 1. Halt at iteration 5 per abandonment criterion. - ---- - -### Task 8: Commit atomically - -- [ ] **Step 1: Review staged diff** - -```bash -git status --short -git diff --stat -``` - -Expected: ~50 files touched. No unexpected files (e.g., `TelegramCore` or `Postbox` edits). - -- [ ] **Step 2: Stage and commit** - -```bash -git add \ - submodules/AccountContext/Sources/AccountContext.swift \ - submodules/TelegramUI/Sources/SharedAccountContext.swift \ - # ... (all touched files) - -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 39 - -Migrate AccountContext.makePeerInfoController's peer: parameter from -raw Peer to EnginePeer. Body-shadow pattern preserves downstream -peerInfoControllerImpl as a raw-Peer consumer (out of scope). - -73 consumer call sites across ~50 files: 58 Shape-A _asPeer() drops, -3 Shape-A-variant guard-statement drops, 12 Shape-C EnginePeer(...) -wraps. Net -49 bridges. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 3: Verify commit** - -```bash -git log --oneline -n 3 -git show --stat HEAD -``` - ---- - -### Task 9: Update memory + log + CLAUDE.md - -- [ ] **Step 1: Append outcome entry to `docs/superpowers/postbox-refactor-log.md`** - -Append a "Wave 39 outcome" section with: commit SHA, file count, line-change count, Shape-A/Shape-A-variant/Shape-C tallies, build iteration count, any surprises, any lesson worth adding to wave-selection guidance. - -- [ ] **Step 2: Update `project_postbox_refactor_next_wave.md` memory** - -Rewrite "Latest commits" and "Wave 39/40 candidates" sections. Promote `makeChatQrCodeScreen` + `makeChatRecentActionsController` to wave 40 (the trivial follow-up). Keep `RenderedPeer → EngineRenderedPeer`, accountManager-side engine path, Shape-C resourceData, and cached-rep triple on the shortlist. - -- [ ] **Step 3: Update `CLAUDE.md` wave tally** - -The "Waves landed so far" line on CLAUDE.md:44 currently says "36 waves plus standalone cleanups" (stale — waves 37 and 38 landed 2026-04-24 but CLAUDE.md wasn't updated). Bump the tally to "39 waves plus standalone cleanups". - -- [ ] **Step 4: Commit the docs update** - -```bash -git add docs/superpowers/postbox-refactor-log.md CLAUDE.md -git commit -m "$(cat <<'EOF' -docs: log wave 39 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Memory file is in `~/.claude/projects/.../memory/` — saved via Write, not git-committed. - ---- - -## Self-review checklist (run before handoff) - -- [x] Every numbered Shape-A site in the spec has a corresponding Step in Task 5 (or Task 2/3 for in-signature-file sites). -- [x] Every Shape-C site in the spec table has a corresponding Step in Task 4. -- [x] Protocol + impl signatures shown verbatim. -- [x] Build command includes `source ~/.zshrc 2>/dev/null;` prefix and `--continueOnError`. -- [x] Abandonment criteria inherited from spec. -- [x] No placeholders, TBD, or "implement later". -- [x] Commit message matches the project's wave-NN style (see `git log` output for prior waves). diff --git a/docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md deleted file mode 100644 index 560e79b10c..0000000000 --- a/docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md +++ /dev/null @@ -1,658 +0,0 @@ -# Wave 43 plan: PeerInfoScreen helpers `peer: Peer?` → `peer: EnginePeer?` - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate six PeerInfoScreen module helpers (`canEditPeerInfo`, `availableActionsForMemberOfPeer`, `peerInfoHeaderActionButtons`, `peerInfoHeaderButtons`, `peerInfoCanEdit`, `peerInfoIsChatMuted`) from `peer: Peer?` to `peer: EnginePeer?`, rewriting internal `as?`/`is` against concrete `TelegramX` subclasses to `case let .x` / `case .x` enum patterns on `EnginePeer`, and updating all 21 call sites to drop wave-42-installed `._asPeer()` / `?._asPeer()` bridges or add `.flatMap(EnginePeer.init)` / `EnginePeer(...)` wraps as appropriate. - -**Architecture:** In-place signature migration following wave-42 precedent — no new typealiases, no engine wrapper structs, no TelegramCore changes. All edits within `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` (10 files). Single atomic commit. - -**Tech Stack:** Swift, Bazel build, `EnginePeer` enum cases (`.user(TelegramUser)`, `.legacyGroup(TelegramGroup)`, `.channel(TelegramChannel)`, `.secretChat(TelegramSecretChat)`). - -**Preceding waves:** 42 (`PeerInfoScreenData.peer: Peer? → EnginePeer?`) on 2026-04-24. This wave drops ~7 `?._asPeer()` / `._asPeer()` bridges installed then. - -**Expected net wrap change:** ~7 DROPs vs ~12 ADDs → net roughly 0. The headline win is helper-signature migration, not wrap count. Follow-up waves migrating `PeerInfoHeaderEditingContentNode.update`, `PeerInfoEditingAvatarNode.update`, `PeerInfoEditingAvatarOverlayNode.update`, `PeerInfoHeaderNode.update`, `PeerInfoScreenMemberItem.enclosingPeer`, `PeerInfoMembersPane` enclosingPeer param will drop the ADDs introduced here. - -**Build expectation:** 2 iterations likely (per wave-41 lesson — foundational-type migrations rarely first-pass-clean). Hedge for iteration-3 if unexpected property accesses surface. - ---- - -## Pre-flight facts (verified from repo 2026-04-24) - -### EnginePeer cases (from `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:177`) - -``` -case user(TelegramUser) -case legacyGroup(TelegramGroup) -case channel(TelegramChannel) -case secretChat(TelegramSecretChat) -``` - -Forwarded property subset on `EnginePeer` includes `id`, `addressName`, `usernames`, `indexName`, `debugDisplayTitle`, `displayLetters`, `profileImageRepresentations`, `smallProfileImage`, `largeProfileImage`, `isDeleted`, `isScam`, `isFake`, `isVerified`, `isPremium`, `isSubscription`, `isService`, `nameColor`, `verificationIconFileId`, `profileColor`, `effectiveProfileColor`, `emojiStatus`, `backgroundEmojiId`, `profileBackgroundEmojiId`, and (via `LocalizedPeerData/Sources/PeerTitle.swift`) `compactDisplayTitle`, `displayTitle(strings:displayOrder:)`. **Not** forwarded: Peer-specific members like `isCopyProtectionEnabled`, `hasPermission(_:)`, `hasBannedPermission(_:)`, `isDeleted` on user (WAIT: yes forwarded). **Internal helper bodies do not access any non-forwarded Peer members** — all their concrete-type work happens via `as? TelegramX`, which is enum-rewrite territory. Confirmed by reading helper bodies (PeerInfoData.swift:2255–2670). - -### Helper call sites inventory (21 sites across 10 files) - -Running command: - -```bash -grep -rn "\bcanEditPeerInfo\b\|\bavailableActionsForMemberOfPeer\b\|\bpeerInfoHeaderActionButtons\b\|\bpeerInfoHeaderButtons\b\|\bpeerInfoCanEdit\b\|\bpeerInfoIsChatMuted\b" submodules/ --include="*.swift" | grep -v PeerInfoData.swift -``` - -All 21 call sites: - -| # | File | Line | Current arg | Peer var origin | Action | -|---|------|------|-------------|-----------------|--------| -| 1 | `PeerInfoHeaderNode.swift` | 548 | `peer: peer` | method param `peer: Peer?` | ADD-WRAP: `peer: peer.flatMap(EnginePeer.init)` | -| 2 | `PeerInfoHeaderNode.swift` | 549 | `peer: peer` | same | ADD-WRAP | -| 3 | `PeerInfoHeaderNode.swift` | 2361 | `peer: peer` | same | ADD-WRAP | -| 4 | `PeerInfoEditingAvatarNode.swift` | 66 | `peer: peer` | method param `peer: Peer?` (unwrapped via `guard let peer = peer`) | ADD-WRAP: `peer: EnginePeer(peer)` (peer is non-optional `Peer` here) | -| 5 | `PeerInfoEditingAvatarOverlayNode.swift` | 85 | `peer: peer` | method param `peer: Peer?` (unwrapped via `guard let peer = peer`) | ADD-WRAP: `peer: EnginePeer(peer)` | -| 6 | `PeerInfoHeaderEditingContentNode.swift` | 59 | `peer: peer` | method param `peer: Peer?` | ADD-WRAP: `peer: peer.flatMap(EnginePeer.init)` | -| 7 | `PeerInfoHeaderEditingContentNode.swift` | 88 | `peer: peer` | same | ADD-WRAP | -| 8 | `PeerInfoHeaderEditingContentNode.swift` | 93 | `peer: peer` | same | ADD-WRAP | -| 9 | `PeerInfoHeaderEditingContentNode.swift` | 159 | `peer: peer` | same | ADD-WRAP | -| 10 | `PeerInfoHeaderEditingContentNode.swift` | 162 | `peer: peer` | same | ADD-WRAP | -| 11 | `PeerInfoScreenAvatarSetup.swift` | 435 | `peer: peer._asPeer()` | `peer = data.peer` (EnginePeer unwrapped) | DROP: `peer: peer` | -| 12 | `PeerInfoScreenPerformButtonAction.swift` | 62 | `peer: self.data?.peer?._asPeer()` | `self.data?.peer` is `EnginePeer?` | DROP: `peer: self.data?.peer` | -| 13 | `PeerInfoScreenPerformButtonAction.swift` | 397 | `peer: peer._asPeer()` | `peer = data.peer` unwrapped at line 381 | DROP: `peer: peer` | -| 14 | `PeerInfoScreenPerformButtonAction.swift` | 398 | `peer: peer._asPeer()` | same | DROP: `peer: peer` | -| 15 | `PeerInfoScreenOpenMember.swift` | 19 | `peer: enclosingPeer._asPeer()` | `enclosingPeer = self.data?.peer` unwrapped at line 14 | DROP: `peer: enclosingPeer` | -| 16 | `PeerInfoScreen.swift` | 1905 | `peer: group` | `group: TelegramGroup` from `if case let .legacyGroup(group) = data.peer` | CONVERT: `peer: data.peer` | -| 17 | `PeerInfoScreen.swift` | 1961 | `peer: channel` | `channel: TelegramChannel` from `if case let .channel(channel) = data.peer` | CONVERT: `peer: data.peer` | -| 18 | `PeerInfoScreen.swift` | 5857 | `peer: self.data?.peer?._asPeer()` | `self.data?.peer` is `EnginePeer?` | DROP: `peer: self.data?.peer` | -| 19 | `PeerInfoProfileItems.swift` | 853 | `peer: peer._asPeer()` | `peer = data.peer` unwrapped at line 821 | DROP: `peer: peer` | -| 20 | `PeerInfoScreenMemberItem.swift` | 178 | `peer: item.enclosingPeer` | `item.enclosingPeer: Peer` (stored raw) | ADD-WRAP: `peer: EnginePeer(item.enclosingPeer)` | -| 21 | `PeerInfoMembersPane.swift` | 139 | `peer: enclosingPeer` | `enclosingPeer: Peer` (local raw) | ADD-WRAP: `peer: EnginePeer(enclosingPeer)` | - -**Summary:** 7 DROPs (sites 11–15, 18, 19), 10 ADD-WRAPs (1–10, 20, 21 = 12 total ADDs), 2 CONVERTs (16, 17 — from concrete-type arg to whole-EnginePeer; no wrap delta but simpler/safer). - -### `is TelegramX` scan on helper bodies - -Only `peerInfoIsChatMuted` has them (PeerInfoData.swift:2641, 2643). Rewrite pattern: `if peer is TelegramUser` → `if case .user = peer`, `else if peer is TelegramGroup` → `else if case .legacyGroup = peer`. - -No `is TelegramX` checks exist at call sites for these specific helpers (wave-42 would have caught them since call-site `data.peer` is already `EnginePeer?`). - ---- - -## File Structure - -All edits within `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`: - -**Modify (helper definitions):** -- `PeerInfoData.swift:2265–2670` — 6 helper signatures + bodies - -**Modify (call sites):** -- `PeerInfoScreen.swift` (3 sites: 1905, 1961, 5857) -- `PeerInfoHeaderNode.swift` (3 sites: 548, 549, 2361) -- `PeerInfoEditingAvatarNode.swift` (1 site: 66) -- `PeerInfoEditingAvatarOverlayNode.swift` (1 site: 85) -- `PeerInfoHeaderEditingContentNode.swift` (5 sites: 59, 88, 93, 159, 162) -- `PeerInfoScreenAvatarSetup.swift` (1 site: 435) -- `PeerInfoScreenPerformButtonAction.swift` (3 sites: 62, 397, 398) -- `PeerInfoScreenOpenMember.swift` (1 site: 19) -- `PeerInfoProfileItems.swift` (1 site: 853) -- `ListItems/PeerInfoScreenMemberItem.swift` (1 site: 178) -- `Panes/PeerInfoMembersPane.swift` (1 site: 139) - -Total: 10 files modified (PeerInfoData.swift counts once). - ---- - -### Task 1: Migrate the six helper signatures and bodies in `PeerInfoData.swift` - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift:2265–2670` - -- [ ] **Step 1: Migrate `canEditPeerInfo` (line 2265).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Body rewrites: - -```swift -// before (line 2269-2287) -if let user = peer as? TelegramUser, let botInfo = user.botInfo { - return botInfo.flags.contains(.canEdit) -} else if let channel = peer as? TelegramChannel { - if let threadData = threadData { - if chatLocation.threadId == 1 { - return false - } - if channel.hasPermission(.manageTopics) { - return true - } - if threadData.author == context.account.peerId { - return true - } - } else { - if channel.hasPermission(.changeInfo) { - return true - } - } -} else if let group = peer as? TelegramGroup { - switch group.role { - case .admin, .creator: - return true - case .member: - break - } - if !group.hasBannedPermission(.banChangeInfo) { - return true - } -} - -// after -if case let .user(user) = peer, let botInfo = user.botInfo { - return botInfo.flags.contains(.canEdit) -} else if case let .channel(channel) = peer { - if let threadData = threadData { - if chatLocation.threadId == 1 { - return false - } - if channel.hasPermission(.manageTopics) { - return true - } - if threadData.author == context.account.peerId { - return true - } - } else { - if channel.hasPermission(.changeInfo) { - return true - } - } -} else if case let .legacyGroup(group) = peer { - switch group.role { - case .admin, .creator: - return true - case .member: - break - } - if !group.hasBannedPermission(.banChangeInfo) { - return true - } -} -``` - -The `if context.account.peerId == peer?.id` line (2266) stays identical (`.id` is forwarded by `EnginePeer`). - -- [ ] **Step 2: Migrate `availableActionsForMemberOfPeer` (line 2314).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Body rewrites — four `peer as? TelegramChannel/TelegramGroup` sites become `case let .channel/.legacyGroup` patterns: - -```swift -// Line 2320: if let channel = peer as? TelegramChannel -// → : if case let .channel(channel) = peer - -// Line 2324: } else if let group = peer as? TelegramGroup { -// → : } else if case let .legacyGroup(group) = peer { - -// Line 2330: if let channel = peer as? TelegramChannel -// → : if case let .channel(channel) = peer - -// Line 2374: } else if let group = peer as? TelegramGroup { -// → : } else if case let .legacyGroup(group) = peer { -``` - -The `if peer == nil` check (line 2317) stays identical (Optional == nil works on EnginePeer? too). - -- [ ] **Step 3: Migrate `peerInfoHeaderActionButtons` (line 2434).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Single body rewrite at line 2436: - -```swift -// before -if !isContact && !isSecretChat, let user = peer as? TelegramUser, user.botInfo == nil { - -// after -if !isContact && !isSecretChat, case let .user(user) = peer, user.botInfo == nil { -``` - -- [ ] **Step 4: Migrate `peerInfoHeaderButtons` (line 2447).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Three body rewrites: - -```swift -// Line 2449: if let user = peer as? TelegramUser { -// → : if case let .user(user) = peer { - -// Line 2483: } else if let channel = peer as? TelegramChannel { -// → : } else if case let .channel(channel) = peer { - -// Line 2558: } else if let group = peer as? TelegramGroup { -// → : } else if case let .legacyGroup(group) = peer { -``` - -- [ ] **Step 5: Migrate `peerInfoCanEdit` (line 2585).** Signature: `peer: Peer?` → `peer: EnginePeer?`. Three body rewrites. Note: original shadows `peer` inside each branch (`let peer = peer as? TelegramX`). Rewrite preserves the shadowing via `case let`: - -```swift -// Line 2586: if let user = peer as? TelegramUser { -// → : if case let .user(user) = peer { - -// Line 2597: } else if let peer = peer as? TelegramChannel { -// → : } else if case let .channel(peer) = peer { -// (intentional shadow of outer `peer` with inner `peer: TelegramChannel` — preserved) - -// Line 2618: } else if let peer = peer as? TelegramGroup { -// → : } else if case let .legacyGroup(peer) = peer { -``` - -- [ ] **Step 6: Migrate `peerInfoIsChatMuted` (line 2633).** Outer signature: `peer: Peer?` → `peer: EnginePeer?`. Inner function signature (line 2634) also migrates: `func isPeerMuted(peer: Peer?, ...)` → `func isPeerMuted(peer: EnginePeer?, ...)`. Body rewrites inside the inner function (line 2641–2651): - -```swift -// before (line 2641) -if peer is TelegramUser { - peerIsMuted = !globalNotificationSettings.privateChats.enabled -} else if peer is TelegramGroup { - peerIsMuted = !globalNotificationSettings.groupChats.enabled -} else if let channel = peer as? TelegramChannel { - switch channel.info { - case .group: - peerIsMuted = !globalNotificationSettings.groupChats.enabled - case .broadcast: - peerIsMuted = !globalNotificationSettings.channels.enabled - } -} - -// after -if case .user = peer { - peerIsMuted = !globalNotificationSettings.privateChats.enabled -} else if case .legacyGroup = peer { - peerIsMuted = !globalNotificationSettings.groupChats.enabled -} else if case let .channel(channel) = peer { - switch channel.info { - case .group: - peerIsMuted = !globalNotificationSettings.groupChats.enabled - case .broadcast: - peerIsMuted = !globalNotificationSettings.channels.enabled - } -} -``` - -The outer `if let peer = peer` (line 2640) stays unchanged (Optional binding works on EnginePeer?). - -The inner `peerInfoIsChatMuted` body (line 2659–2669) calls `isPeerMuted(peer: peer, ...)` with the outer `peer` (now EnginePeer?) — works without change because inner signature now matches. - -- [ ] **Step 7: Re-read PeerInfoData.swift lines 2265–2670 and visually verify no `as? TelegramX` or `is TelegramX` patterns remain.** - -Run: `grep -n "as? TelegramUser\|as? TelegramChannel\|as? TelegramGroup\|is TelegramUser\|is TelegramChannel\|is TelegramGroup" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift | awk -F: '$2 >= 2265 && $2 <= 2670'` -Expected: empty output. - ---- - -### Task 2: Update call sites — DROPs (7 sites) - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift:435` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift:62,397,398` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift:19` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:5857` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift:853` - -- [ ] **Step 1: DROP at `PeerInfoScreenAvatarSetup.swift:435`.** - -```swift -// before -guard let data = self.controllerNode.data, let peer = data.peer, mode != .generic || canEditPeerInfo(context: self.context, peer: peer._asPeer(), chatLocation: self.chatLocation, threadData: data.threadData) else { - -// after -guard let data = self.controllerNode.data, let peer = data.peer, mode != .generic || canEditPeerInfo(context: self.context, peer: peer, chatLocation: self.chatLocation, threadData: data.threadData) else { -``` - -- [ ] **Step 2: DROP at `PeerInfoScreenPerformButtonAction.swift:62`.** - -```swift -// before -let chatIsMuted = peerInfoIsChatMuted(peer: self.data?.peer?._asPeer(), peerNotificationSettings: self.data?.peerNotificationSettings, threadNotificationSettings: self.data?.threadNotificationSettings, globalNotificationSettings: self.data?.globalNotificationSettings) - -// after -let chatIsMuted = peerInfoIsChatMuted(peer: self.data?.peer, peerNotificationSettings: self.data?.peerNotificationSettings, threadNotificationSettings: self.data?.threadNotificationSettings, globalNotificationSettings: self.data?.globalNotificationSettings) -``` - -- [ ] **Step 3: DROP at `PeerInfoScreenPerformButtonAction.swift:397 and :398` (peerInfoHeaderButtons, two lines, same pattern `peer: peer._asPeer()` → `peer: peer`).** - -Use Edit with `replace_all=true` on the substring `peer: peer._asPeer(), cachedData: data.cachedData` — this exact form appears exactly twice in the file (lines 397, 398), both targets. - -```swift -// before (at both 397 and 398) -peerInfoHeaderButtons(peer: peer._asPeer(), cachedData: data.cachedData, isOpenedFromChat: ... - -// after -peerInfoHeaderButtons(peer: peer, cachedData: data.cachedData, isOpenedFromChat: ... -``` - -Verification after edit: - -```bash -grep -n "_asPeer()" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift -``` -Expected: empty (all three DROPs done). - -- [ ] **Step 4: DROP at `PeerInfoScreenOpenMember.swift:19`.** - -```swift -// before -let actions = availableActionsForMemberOfPeer(accountPeerId: self.context.account.peerId, peer: enclosingPeer._asPeer(), member: member) - -// after -let actions = availableActionsForMemberOfPeer(accountPeerId: self.context.account.peerId, peer: enclosingPeer, member: member) -``` - -- [ ] **Step 5: DROP at `PeerInfoScreen.swift:5857`.** - -```swift -// before -} else if peerInfoCanEdit(peer: self.data?.peer?._asPeer(), chatLocation: self.chatLocation, threadData: self.data?.threadData, cachedData: self.data?.cachedData, isContact: self.data?.isContact) { - -// after -} else if peerInfoCanEdit(peer: self.data?.peer, chatLocation: self.chatLocation, threadData: self.data?.threadData, cachedData: self.data?.cachedData, isContact: self.data?.isContact) { -``` - -- [ ] **Step 6: DROP at `PeerInfoProfileItems.swift:853`.** - -Only the `availableActionsForMemberOfPeer` call — the sibling `enclosingPeer: peer._asPeer()` at line 852 is NOT a helper-migration target (it's `PeerInfoScreenMemberItem.enclosingPeer: Peer`, unchanged in this wave). - -```swift -// before (line 853) -let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: peer._asPeer(), member: member) - -// after -let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: peer, member: member) -``` - ---- - -### Task 3: Update call sites — CONVERTs (2 sites in PeerInfoScreen.swift) - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift:1905,1961` - -At these sites the helper arg is currently a concrete `TelegramGroup` / `TelegramChannel` extracted via case pattern. After migration the helper takes `EnginePeer?`, so pass `data.peer` directly — the helper re-does the pattern match internally, semantics preserved. - -- [ ] **Step 1: CONVERT at `PeerInfoScreen.swift:1905`.** - -```swift -// before -} else if case let .legacyGroup(group) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: group, chatLocation: chatLocation, threadData: data.threadData) { - -// after -} else if case let .legacyGroup(group) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: data.peer, chatLocation: chatLocation, threadData: data.threadData) { -``` - -`group` stays bound because the body below still uses it. Only the helper arg changes. - -- [ ] **Step 2: CONVERT at `PeerInfoScreen.swift:1961`.** - -```swift -// before -} else if case let .channel(channel) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: channel, chatLocation: strongSelf.chatLocation, threadData: data.threadData) { - -// after -} else if case let .channel(channel) = data.peer, canEditPeerInfo(context: strongSelf.context, peer: data.peer, chatLocation: strongSelf.chatLocation, threadData: data.threadData) { -``` - ---- - -### Task 4: Update call sites — ADD-WRAPs in internal-update methods (10 sites in 4 files) - -These files' internal `.update(peer: Peer?, ...)` methods are NOT migrated in this wave (scope: helpers only). Each helper call inside bridges `peer` (raw `Peer?`) to `EnginePeer?` via `.flatMap(EnginePeer.init)`, or — where `peer` has already been unwrapped to non-optional `Peer` — via `EnginePeer(peer)`. - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift:548,549,2361` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift:66` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift:85` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift:59,88,93,159,162` - -- [ ] **Step 1: ADD-WRAPs at `PeerInfoHeaderNode.swift:548,549,2361`.** At lines 548, 549, the local `peer` is the raw `Peer?` method parameter (line 496). At line 2361 likewise. - -```swift -// before (line 548) -let actionButtonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderActionButtons(peer: peer, isSecretChat: isSecretChat, isContact: isContact) - -// after -let actionButtonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderActionButtons(peer: peer.flatMap(EnginePeer.init), isSecretChat: isSecretChat, isContact: isContact) -``` - -```swift -// before (line 549) -let buttonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderButtons(peer: peer, cachedData: cachedData, ...) - -// after -let buttonKeys: [PeerInfoHeaderButtonKey] = (self.isSettings || self.isMyProfile) ? [] : peerInfoHeaderButtons(peer: peer.flatMap(EnginePeer.init), cachedData: cachedData, ...) -``` - -```swift -// before (line 2361) -let chatIsMuted = peerInfoIsChatMuted(peer: peer, peerNotificationSettings: peerNotificationSettings, threadNotificationSettings: threadNotificationSettings, globalNotificationSettings: globalNotificationSettings) - -// after -let chatIsMuted = peerInfoIsChatMuted(peer: peer.flatMap(EnginePeer.init), peerNotificationSettings: peerNotificationSettings, threadNotificationSettings: threadNotificationSettings, globalNotificationSettings: globalNotificationSettings) -``` - -- [ ] **Step 2: ADD-WRAP at `PeerInfoEditingAvatarNode.swift:66`.** Here `peer` is non-optional `Peer` (unwrapped at line 62: `guard let peer = peer else { return }`). Use `EnginePeer(peer)`. - -```swift -// before -let canEdit = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) - -// after -let canEdit = canEditPeerInfo(context: self.context, peer: EnginePeer(peer), chatLocation: chatLocation, threadData: threadData) -``` - -- [ ] **Step 3: ADD-WRAP at `PeerInfoEditingAvatarOverlayNode.swift:85`.** Same shape — `peer` is non-optional `Peer` (unwrapped at line 64). - -```swift -// before -if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) - -// after -if canEditPeerInfo(context: self.context, peer: EnginePeer(peer), chatLocation: chatLocation, threadData: threadData) -``` - -- [ ] **Step 4: ADD-WRAPs at `PeerInfoHeaderEditingContentNode.swift:59,88,93,159,162`.** Here `peer` is the method's `peer: Peer?` parameter (line 52). Five identical bridge forms. - -For each of lines 59, 88, 93, 159, 162, replace `peer: peer` (inside `canEditPeerInfo(... peer: peer, ...)`) with `peer: peer.flatMap(EnginePeer.init)`. - -The simplest approach: issue five separate Edit calls, each scoped to a unique surrounding substring. Example: - -```swift -// before (line 59) -if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) { - -// after -if canEditPeerInfo(context: self.context, peer: peer.flatMap(EnginePeer.init), chatLocation: chatLocation, threadData: threadData) { -``` - -Note line 59's trailing double-space before `{` in the original — preserve it. - -Lines 88, 93, 159 share an identical surrounding substring `if canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) {` (no trailing double-space, no `|| isEditableBot`). To avoid collision with line 59, use `replace_all=true` on THIS exact string (matches 88, 93 — wait, 159 uses `isEnabled = canEditPeerInfo(...)`, different prefix). Safer plan: one Edit per line, each with enough surrounding context to be unique. Verify uniqueness after each edit with grep. - -Line 88's surrounding context: inside `if let _ = peer as? TelegramGroup {` branch — preceded by `fieldKeys.append(.title)`. - -Line 93's surrounding context: inside `if let _ = peer as? TelegramChannel {` branch — preceded by `fieldKeys.append(.title)`. Same inner phrase as 88 — so `fieldKeys.append(.title)\n if canEditPeerInfo...` appears twice. Use line-specific context (preceding `else if let _ = peer as?` token). - -Line 159: `isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData)` (no trailing text). - -Line 162: `isEnabled = canEditPeerInfo(context: self.context, peer: peer, chatLocation: chatLocation, threadData: threadData) || isEditableBot`. Unique — contains ` || isEditableBot`. - -Recommended: five sequential Edits with explicit line disambiguation via surrounding context. Do not bulk-replace-all — the identical `peer: peer, chatLocation: chatLocation, threadData: threadData)` substring appears at all five sites but their line-specific surroundings differ. - -Verification after all five edits: - -```bash -grep -c "peer: peer," submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift -``` -Expected: 0 (no unmigrated call sites remain; other `peer:` occurrences in the file are either type annotations or at the method signature, which uses `peer: Peer?` not `peer: peer`). - ---- - -### Task 5: Update call sites — ADD-WRAPs at raw-`Peer` member-item sites (2 sites) - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift:178` -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift:139` - -At these sites `enclosingPeer` is non-optional `Peer` (raw, stored on the item / local). Wrap with `EnginePeer(...)`. - -- [ ] **Step 1: ADD-WRAP at `PeerInfoScreenMemberItem.swift:178`.** - -```swift -// before -let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer, member: item.member) - -// after -let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: EnginePeer(item.enclosingPeer), member: item.member) -``` - -- [ ] **Step 2: ADD-WRAP at `PeerInfoMembersPane.swift:139`.** - -```swift -// before -let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: enclosingPeer, member: member) - -// after -let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: EnginePeer(enclosingPeer), member: member) -``` - ---- - -### Task 6: Build and iterate - -- [ ] **Step 1: Full project build with `--continueOnError` to surface all errors at once.** - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: likely 2-iteration convergence. Budget up to iteration 3. - -Likely categories of residual errors: - -1. **Missed call sites** — grep-miss from planning. Remediate by adding `.flatMap(EnginePeer.init)` or `EnginePeer(...)` as appropriate. -2. **Missed `as? TelegramX` / `is TelegramX` inside helper bodies** — Swift compiler error "cannot convert value of type 'EnginePeer?' to expected argument type 'Peer?'" or warning "'is' test is always false". Fix with `case` pattern. -3. **Optional-lifting edge cases** — `if case let .user(user) = peer` may fail if Swift interprets `peer` as non-optional. If so, rewrite as `if let peer, case let .user(user) = peer`. -4. **Unused binding warnings** — e.g. `if case let .user(user) = peer` where `user` isn't used inside that branch. Swift's `-warnings-as-errors` (658/665 submodule BUILDs) promotes these. Rewrite as `if case .user = peer`. -5. **Unused variable `peer` or `group`/`channel` at CONVERT sites 16, 17** — lines 1905/1961 bind `group`/`channel` in the `case let` pattern; if the body body doesn't use it, Swift emits "value 'group' was never used" which `-warnings-as-errors` promotes to error. Since the body below DOES use them (updatePeerTitle(peerId: group.id, ...)` etc.), this should not trigger — but verify. - -- [ ] **Step 2: For each error category above, apply the correct fix in-place and rebuild. Iterate until green.** - -- [ ] **Step 3: After build is green, run the post-migration grep audit:** - -```bash -# Should be empty — no _asPeer() bridges at helper call sites -grep -rn "canEditPeerInfo(.*_asPeer\|peerInfoIsChatMuted(.*_asPeer\|peerInfoHeaderButtons(.*_asPeer\|peerInfoHeaderActionButtons(.*_asPeer\|peerInfoCanEdit(.*_asPeer\|availableActionsForMemberOfPeer(.*_asPeer" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ - -# Should be empty — no concrete-type casts against peer param in helper bodies -grep -nE "as\?\s+TelegramUser|as\?\s+TelegramChannel|as\?\s+TelegramGroup|\bis\s+TelegramUser\b|\bis\s+TelegramChannel\b|\bis\s+TelegramGroup\b" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift | awk -F: '$2 >= 2265 && $2 <= 2670' -``` - -Expected: both empty. - ---- - -### Task 7: Commit - -- [ ] **Step 1: Verify working tree only contains wave-43 edits + pre-existing WIP.** - -```bash -git status --short -``` - -Expected (pre-existing WIP, NOT to be staged): - -``` - m build-system/bazel-rules/sourcekit-bazel-bsp - M submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift -?? build-system/tulsi/ -?? submodules/TgVoip/ -?? third-party/libx264/ -``` - -Plus wave-43 edits (all under `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`): - -``` - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift - M submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift -``` - -- [ ] **Step 2: Explicitly stage only the wave-43 files (not the WIP).** - -```bash -git add \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarOverlayNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderEditingContentNode.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenAvatarSetup.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenOpenMember.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift \ - docs/superpowers/plans/2026-04-24-peerinfoscreen-helpers-engine-peer-migration.md -``` - -- [ ] **Step 3: Commit.** - -Use a HEREDOC for the message: - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 43 - -Migrate six PeerInfoScreen helpers (canEditPeerInfo, -availableActionsForMemberOfPeer, peerInfoHeaderActionButtons, -peerInfoHeaderButtons, peerInfoCanEdit, peerInfoIsChatMuted) from -`peer: Peer?` to `peer: EnginePeer?`. Internal `as? TelegramX` / -`is TelegramX` patterns rewritten to `case let .x` / `case .x` on -EnginePeer enum. All 21 call sites updated in the same commit: 7 -`._asPeer()` bridges installed by wave 42 dropped; 12 -`.flatMap(EnginePeer.init)` / `EnginePeer(...)` wraps added at sites -whose enclosing methods still take raw Peer?; 2 concrete-type args -converted to pass the whole EnginePeer value. - -All edits within submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/. -No new engine typealiases. No TelegramCore changes. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit.** - -```bash -git log --oneline -1 -git show --stat HEAD -``` - -Expected: one commit, ~10 files changed, clean diff. - ---- - -## Self-review checklist (run before handoff) - -**Spec coverage:** -- All 6 helper signatures migrated (Task 1 steps 1–6). ✓ -- All 21 call sites touched (Tasks 2–5). ✓ -- Build iteration explicit (Task 6). ✓ -- Commit explicit (Task 7). ✓ - -**Type consistency:** -- Helper signatures all `peer: EnginePeer?` (consistent). ✓ -- Call-site transforms: DROP/ADD/CONVERT actions match the inventory table. ✓ -- `EnginePeer.init` constructor used both as `.flatMap(EnginePeer.init)` (Peer? → EnginePeer?) and `EnginePeer(...)` (Peer → EnginePeer) — both are valid (construction overloaded on EnginePeer extension at `TelegramCore/TelegramEngine/Peers/Peer.swift:564`). ✓ - -**Placeholder scan:** -- No "TBD" / "handle appropriately" / "similar to Task N" language — every step has its concrete code. ✓ - -**Risks flagged:** -- Wave-41 lesson: foundational-type migrations rarely first-pass-clean. Budget 2 iterations. ✓ -- Wave-41 lesson: `-warnings-as-errors` promotes always-false `is` checks and unused bindings to build errors. Task 6 step 1 calls these out explicitly. ✓ -- Wave-42 lesson: `EnginePeer` doesn't forward every Peer property. Helper bodies were verified to access only `.id`, which IS forwarded; other property accesses were on concrete types (`TelegramChannel.hasPermission(...)` etc.) which remain on concrete types post-migration. No forwarding-gap remediation expected in helpers. ✓ diff --git a/docs/superpowers/plans/2026-04-24-peerinfoscreendata-peer-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-peerinfoscreendata-peer-engine-peer-migration.md deleted file mode 100644 index a3fc76f355..0000000000 --- a/docs/superpowers/plans/2026-04-24-peerinfoscreendata-peer-engine-peer-migration.md +++ /dev/null @@ -1,164 +0,0 @@ -# Wave 42 plan: `PeerInfoScreenData.peer: Peer? → EnginePeer?` - -Date: 2026-04-24 -Preceding waves: 41 (`RenderedChannelParticipant.peer`), 40 (`makeChatQrCodeScreen`/`makeChatRecentActionsController`), 39 (`makePeerInfoController`) -Scope (confirmed with user): only `PeerInfoScreenData.peer`. Sibling fields (`chatPeer`, `savedMessagesPeer`, `linkedDiscussionPeer`, `linkedMonoforumPeer`) are follow-up-wave candidates. - -## Change target - -File: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift` - -- L386: `let peer: Peer?` → `let peer: EnginePeer?` -- L442: `peer: Peer?,` → `peer: EnginePeer?,` -- Store unchanged (`self.peer = peer`) - -## Construction sites (5, all in PeerInfoData.swift) - -| Line | Current `peer:` arg | Rewrite | -|------|---------------------|---------| -| 1027 | `peer: peer` (local, `Peer?` from `peerView.peers[peerId]`) | `peer: peer.flatMap(EnginePeer.init)` | -| 1100 | `peer: nil` | unchanged | -| 1620 | `peer: peer` (local, `Peer?` from `peerView.peers[userPeerId]`) | `peer: peer.flatMap(EnginePeer.init)` | -| 1867 | `peer: peerView.peers[peerId]` | `peer: peerView.peers[peerId].flatMap(EnginePeer.init)` | -| 2205 | `peer: peerView.peers[groupId]` | `peer: peerView.peers[groupId].flatMap(EnginePeer.init)` | - -## Consumer migration patterns (across 18 files, ~114 `data.peer` accesses) - -### Pattern A — as-cast → enum pattern match (~20 sites) - -```swift -// before -if let user = data.peer as? TelegramUser, user.botInfo == nil { ... } - -// after -if case let .user(user) = data.peer, user.botInfo == nil { ... } -``` - -Scope both sides consistently. A cast inside a larger `guard let ..., let user = ... as? TelegramUser else { return }` becomes `guard ..., case let .user(user) = data.peer else { return }`. - -### Pattern B — `is TelegramXxx` check → enum case pattern (~5 sites) - -The wave-41 lesson: `-warnings-as-errors` catches always-false `is` checks. - -```swift -// before -if let peer = self.data?.peer, peer is TelegramChannel { ... } -if peer is TelegramGroup { ... } - -// after -if case .channel = self.data?.peer { ... } -if case .legacyGroup = peer { ... } -``` - -`TelegramGroup` maps to `.legacyGroup`. `TelegramChannel` maps to `.channel`. `TelegramUser` maps to `.user`. `TelegramSecretChat` maps to `.secretChat`. - -Known sites in PeerInfoScreen.swift (inventory): L3981, L4133, L4192, L4194 (and L7421 for `chatPeer`-bound — chatPeer stays raw, so L7421 is out of scope). Use repo grep on `PeerInfoScreen/Sources` with token `is Telegram(Channel|User|Group|SecretChat)` to catch other sites. - -### Pattern C — existing `EnginePeer(peer)` wraps where `peer` was bound from `data.peer` — DROP (15+ sites) - -```swift -// before -if let peer = self.data?.peer { - self.joinChannel(peer: EnginePeer(peer)) // wave-40 wrap -} - -// after -if let peer = self.data?.peer { - self.joinChannel(peer: peer) // peer is now EnginePeer already -} -``` - -Care needed: only drop the wrap where the bound `peer` variable comes from `data.peer`. Wraps on `chatPeer`, `currentPeer`, `user` (bound via `as? TelegramUser`), `groupPeer`, or PeerView lookups stay. The lexical scope makes this judgeable. - -Known drop sites (PeerInfoScreen.swift): 1331, 1339, 1346, 1561, 2353, 2405, 3409, 3459, 3624, 3747, 4306, 4573 (inner — review scope), 4623. PeerInfoHeaderNode.swift: 571, 1218, 2054 (if bound from data.peer). PeerInfoScreenOpenChat.swift: 25, 40, 51, 57, 80, 89, 115. Verify each by backtracking the `if let peer = ...` binding. - -### Pattern D — helper call sites still taking `Peer?` (ADD-WRAP, ~10 sites) - -`canEditPeerInfo`, `peerInfoIsChatMuted`, `peerInfoHeaderButtons`, `peerInfoHeaderActionButtons`, `peerInfoCanEdit`, `availableActionsForMemberOfPeer` all keep `peer: Peer?` in this wave. Call sites must bridge: - -```swift -// before -peerInfoIsChatMuted(peer: self.data?.peer, ...) - -// after -peerInfoIsChatMuted(peer: self.data?.peer?._asPeer(), ...) -``` - -Site count (from grep): PeerInfoHeaderNode.swift:548/549/2361, PeerInfoScreenAvatarSetup.swift:435, PeerInfoScreenPerformButtonAction.swift:62/397/398, PeerInfoEditingAvatarNode.swift:66, PeerInfoScreen.swift:1905/1961/5857, PeerInfoHeaderEditingContentNode.swift:59/88/93/159/162, PeerInfoEditingAvatarOverlayNode.swift:85. But the local `peer` at some of these is already narrowed via `as? TelegramUser` (now `case let .user(user)`); in that case the helper gets `user` (still `Peer`-conforming), no bridge needed. Bridge only where the raw `data.peer` flows into the helper. - -These ADD-WRAP markers become ratchet-drops for a follow-up wave that migrates the helper signatures. - -### Pattern E — `EnginePeer?` passed as `EnginePeer?` directly (DROP wraps on callback args) - -Where `data.peer` feeds `makePeerInfoController(peer: EnginePeer)` / `chatInterfaceInteraction.openPeer(_ peer: EnginePeer, ...)` / `.peer(EnginePeer)` ChatLocation / `AvatarGalleryController(peer: EnginePeer)` / `makeChatQrCodeScreen(peer: EnginePeer)` / `makeChatRecentActionsController(peer: EnginePeer)` — drop the `EnginePeer(...)` wrap; pass directly. - -### Pattern F — `EnginePeer(peer).displayTitle(...)` / `.compactDisplayTitle` usage (DROP wrap) - -```swift -// before -EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...) - -// after (peer is now EnginePeer already) -peer.displayTitle(strings: ..., displayOrder: ...) -``` - -### Pattern G — `.isPremium` on `peer?` inside construction site (L1060, L1626, L1902, L2242) - -`peerView.peers[peerId]?.isPremium` — `Peer` protocol exposes `isPremium`. But the construction site receives raw `Peer?` and then we wrap via `flatMap(EnginePeer.init)`. The `peer?.isPremium` in the same construction scope still refers to the *local* raw peer variable (type unchanged), not `self.peer`. **No change needed at construction sites for `.isPremium` accesses on the local raw `peer`.** Only change `.isPremium` accesses on `data.peer` (which is now `EnginePeer?`) — `EnginePeer.isPremium` exists. - -## File-by-file plan - -1. **PeerInfoData.swift** — declaration + init + 5 constructions. Also review L1529 (`peerView.peers[peerView.peerId] is TelegramUser`) — OUT OF SCOPE (not `data.peer`); don't touch. Helper functions L2265/2314/2434/2447/2585/2633 stay `peer: Peer?` — DO NOT TOUCH. - -2. **PeerInfoScreen.swift** — largest consumer, ~70+ sites. Walk every `data.peer` / `data?.peer` / `self.data?.peer` / `self.data.peer`. Apply A/B/C/E/F patterns. For `if let peer = data.peer` bindings, subsequent uses of `peer` now have type `EnginePeer` — drop wraps on those uses. - -3. **PeerInfoScreenOpenChat.swift, PeerInfoScreenOpenBio.swift, PeerInfoScreenOpenMember.swift, PeerInfoScreenOpenPeerInfoContextMenu.swift, PeerInfoScreenOpenUsername.swift, PeerInfoScreenCallActions.swift, PeerInfoScreenMessageActions.swift, PeerInfoScreenPerformButtonAction.swift, PeerInfoScreenAvatarSetup.swift, PeerInfoScreenSettingsActions.swift, PeerInfoScreenDisplayGiftsContextMenu.swift, PeerInfoScreenDisplayMediaGalleryContextMenu.swift** — various `data?.peer as? TelegramXxx` (A), helper bridges (D), wrap drops (C/E). - -4. **PeerInfoPaneContainerNode.swift** — L1252 `as? TelegramChannel` (A). - -5. **PeerInfoProfileItems.swift, PeerInfoSettingsItems.swift, ListItems/PeerInfoScreenPersonalChannelItem.swift** — `data.peer as? TelegramUser` style consumers (A). - -6. **PeerInfoHeaderNode.swift, PeerInfoEditingAvatarNode.swift, PeerInfoEditingAvatarOverlayNode.swift, PeerInfoHeaderEditingContentNode.swift** — these files receive `peer` as a parameter (not directly `data.peer`). Only touch if a parameter type declared as `Peer?` is the field from `data.peer` being passed in; otherwise leave. - -## Replace_all guidance (wave-41 lesson) - -Several wraps repeat identically. Where a file has multiple identical `EnginePeer(peer)` expressions in scopes where `peer` is now `EnginePeer`, use `replace_all=true` on the unique full expression. BUT verify each such file has no same-pattern wrap where `peer` is still raw (chatPeer-bound, currentPeer-bound, etc.) — such wraps must survive. - -Safer alternative: edit each site individually. - -## Out of scope (enumerated) - -- `PeerInfoScreenData.chatPeer`, `.savedMessagesPeer`, `.linkedDiscussionPeer`, `.linkedMonoforumPeer` — stay `Peer?`. -- Internal helpers `canEditPeerInfo` / `peerInfoIsChatMuted` / etc. — stay `peer: Peer?`. -- `peerView.peers[...]` access inside PeerInfoData.swift — stays raw `Peer?`. -- Any `is TelegramXxx` check on a non-`data.peer`-derived variable. - -## Build methodology - -1. Apply declaration + init + construction edits. -2. Apply consumer edits file by file. -3. `source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError` -4. Iterate on errors. Budget: 2–4 iterations (wave-41 lesson: foundational-type property-access migrations are not first-pass-clean). - -## Expected ratchet math - -- Drops: 15+ wave-40 wraps, ~20 as-cast patterns collapsed, ~5 is-checks rewritten, several `EnginePeer(peer).displayTitle` wraps dropped. -- Adds: ~10 `?._asPeer()` helper bridges, 4 `flatMap(EnginePeer.init)` at construction. -- Net: ~15–25 bridges dropped. - -## WIP interference check - -Pre-existing WIP in tree (per memory): -- `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift` — modified, unrelated animation WIP. DO NOT TOUCH. -- `submodules/TgVoip/`, `third-party/libx264/`, `build-system/tulsi/` — untracked, unrelated. -- `build-system/bazel-rules/sourcekit-bazel-bsp` — submodule marker, unrelated. - -Wave-42 files are all in `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/` — no overlap with WIP. Commit with explicit file list (wave-39/41 lesson). - -## Post-commit followups - -- Update `docs/superpowers/postbox-refactor-log.md` with "Wave 42 outcome". -- Update `memory/project_postbox_refactor_next_wave.md` with wave 43 candidates: - - Wave 42.x sibling: `PeerInfoScreenData.chatPeer` / `.savedMessagesPeer` / `.linkedDiscussionPeer` / `.linkedMonoforumPeer` as a bundle (same file, narrow blast radius). - - Wave 42.y: PeerInfo-internal helper signatures (drops the ~10 ADD-WRAP markers). - - Option 2 from wave-42 shortlist: `RenderedChannelParticipant.peers` dict. diff --git a/docs/superpowers/plans/2026-04-24-peertokentitle-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-peertokentitle-engine-peer-migration.md deleted file mode 100644 index d8386bf99d..0000000000 --- a/docs/superpowers/plans/2026-04-24-peertokentitle-engine-peer-migration.md +++ /dev/null @@ -1,395 +0,0 @@ -# Postbox → TelegramEngine wave 37: `peerTokenTitle` peer parameter Peer → EnginePeer - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the private free function `peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings:, nameDisplayOrder:)` in `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` so `peer` is `EnginePeer`, dropping 5 `._asPeer()` bridges at call sites in the same file. - -**Architecture:** Single-file, atomic, private-function refactor. No public API change, no BUILD-file touch, no cross-module effects. Function body simplifies `EnginePeer(peer).displayTitle(...)` → `peer.displayTitle(...)`. - -**Tech Stack:** Swift, Bazel via `Make.py` wrapper, Telegram-iOS project conventions (see CLAUDE.md). - -**Reference:** Spec `docs/superpowers/specs/2026-04-24-peertokentitle-engine-peer-migration-design.md`. - ---- - -## File Structure - -Only one file is touched: - -- **Modify:** `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` - - L21 — signature change (`peer: Peer` → `peer: EnginePeer`) - - L27 — body simplification (drop redundant `EnginePeer(...)` wrap) - - L171, L201, L386, L403, L748 — call-site bridge drops (`peer: peer._asPeer()` → `peer: peer`) - -No files created. No files deleted. No BUILD files touched. - ---- - -## Task 1: Pre-flight inventory verification - -**Files:** None (grep-only). - -- [ ] **Step 1: Confirm the function is private and single-file** - -Run: - -```bash -grep -rn "peerTokenTitle" submodules/ Telegram/ third-party/ --include="*.swift" -``` - -Expected: exactly 6 matches, all in `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` — 1 definition at L21 and 5 call sites at L171, L201, L386, L403, L748. - -If any match appears outside this file, **stop and re-evaluate scope**: the function may not actually be private or another file has copy-pasted the name. - -- [ ] **Step 2: Confirm all 5 call sites currently use `._asPeer()`** - -Run: - -```bash -grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: 5 matches, line numbers 171, 201, 386, 403, 748. - -If the count is not 5, **stop and re-inventory** — a prior change may have shifted line numbers or altered a call site. - -- [ ] **Step 3: Confirm no other `peerTokenTitle` overload exists** - -Run: - -```bash -grep -n "func peerTokenTitle" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: exactly 1 match at line 21 (`private func peerTokenTitle(...)`). - -- [ ] **Step 4: Confirm `EnginePeer.displayTitle(strings:displayOrder:)` exists** - -Run: - -```bash -grep -rn "func displayTitle(strings:" submodules/TelegramCore/Sources/TelegramEngine/ submodules/TelegramCore/Sources/SyncCore/ -``` - -Expected: a match on `EnginePeer` extension exposing `displayTitle(strings: PresentationStrings, displayOrder: PresentationPersonNameOrder)`. (This is the method already called as `EnginePeer(peer).displayTitle(...)` at L27, so its existence is certain — this step just makes the dependency explicit.) - ---- - -## Task 2: Edit the function signature and body - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift:21-29` - -- [ ] **Step 1: Read the current function definition** - -Read the file, lines 21–29. Current state: - -```swift -private func peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String { - if peer.id == accountPeerId { - return strings.DialogList_SavedMessages - } else if peer.id.isReplies { - return strings.DialogList_Replies - } else { - return EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder) - } -} -``` - -- [ ] **Step 2: Apply the signature change** - -Use Edit with: - -- `old_string`: - ``` - private func peerTokenTitle(accountPeerId: PeerId, peer: Peer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String { - if peer.id == accountPeerId { - return strings.DialogList_SavedMessages - } else if peer.id.isReplies { - return strings.DialogList_Replies - } else { - return EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder) - } - } - ``` -- `new_string`: - ``` - private func peerTokenTitle(accountPeerId: PeerId, peer: EnginePeer, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder) -> String { - if peer.id == accountPeerId { - return strings.DialogList_SavedMessages - } else if peer.id.isReplies { - return strings.DialogList_Replies - } else { - return peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder) - } - } - ``` - -Note: `accountPeerId: PeerId` stays as-is — `PeerId` is already the typealias for `EnginePeer.Id`. `peer.id.isReplies` works unchanged because `EnginePeer.Id` exposes `isReplies`. - ---- - -## Task 3: Drop `._asPeer()` bridges at all 5 call sites - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` (L171, L201, L386, L403, L748) - -All 5 call sites have an identical argument fragment: - -``` -peer: peer._asPeer(), -``` - -…which must become: - -``` -peer: peer, -``` - -The surrounding context differs per site (two distinct `strings/nameDisplayOrder` chains, see below), so we handle the substitution in two batches. - -- [ ] **Step 1: Replace sites L171, L201, L748 (use `strongSelf.presentationData.strings` / `strongSelf.presentationData.nameDisplayOrder` or `self.presentationData.strings` / `self.presentationData.nameDisplayOrder`)** - -Three call sites share identical code but with different leading `accountPeerId` expressions. Apply them individually. - -**L171 and L201 are identical** — both read: - -```swift -return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) -``` - -Use Edit with `replace_all=true`: - -- `old_string`: - ``` - return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) - ``` -- `new_string`: - ``` - return EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: params.context.account.peerId, peer: peer, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) - ``` - -**L748** reads: - -```swift -tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer._asPeer(), strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))) -``` - -Use Edit (no `replace_all` — this line is unique): - -- `old_string`: - ``` - tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer._asPeer(), strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))) - ``` -- `new_string`: - ``` - tokens.append(EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: self.context.account.peerId, peer: peer, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer))) - ``` - -- [ ] **Step 2: Replace sites L386 and L403 (use `accountPeerId` local)** - -**L386 and L403 are identical** — both read: - -```swift -addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) -``` - -Use Edit with `replace_all=true`: - -- `old_string`: - ``` - addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer._asPeer(), strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) - ``` -- `new_string`: - ``` - addedToken = EditableTokenListToken(id: peer.id, title: peerTokenTitle(accountPeerId: accountPeerId, peer: peer, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder), fixedPosition: nil, subject: .peer(peer)) - ``` - -- [ ] **Step 3: Grep to confirm zero remaining bridge sites** - -Run: - -```bash -grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: **0 matches**. - -If any match remains, the previous edits missed a line variant — re-read the file around each missed line and apply a targeted Edit for that variant. - -- [ ] **Step 4: Confirm the 5 expected `peer: peer,` call sites now appear** - -Run: - -```bash -grep -n "peerTokenTitle(.*peer: peer," submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: 5 matches, line numbers approximately 171, 201, 386, 403, 748 (exact numbers unchanged — the edits don't shift line counts). - ---- - -## Task 4: Build verification - -**Files:** None edited in this task. - -- [ ] **Step 1: Run the full project build with --continueOnError** - -Run: - -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -Expected: build succeeds with exit code 0 and no compilation errors. - -**If the build fails:** - -1. Inspect the error output. Three failure modes are anticipated (all should be rare given the scope): - - **Missing `displayTitle` on `EnginePeer`:** unlikely, since L27 was calling it pre-migration. If it happens, verify the `EnginePeer` import chain — but do not add new imports; this file already imports `TelegramCore`. - - **A 6th call site exists** that the pre-flight grep missed (e.g., one using a different string pattern like `peer:peer` with no space, or a multi-line call). Locate it with `grep -n "peerTokenTitle" submodules/TelegramUI/Sources/ContactMultiselectionController.swift` and apply the bridge drop manually. - - **Unrelated type-inference cascade**, e.g., some `peer` local was previously inferred as `Peer` via the callback chain and now can't be. Read the error line and assess: if it's inside the function body or call site, adjust; if it's elsewhere in the file, it was pre-existing and unrelated — still, don't touch it mid-wave. Abandon per wave-rule 5 if scope creep is required. -2. Re-run the build after the fix. - -- [ ] **Step 2: Confirm the post-migration grep is clean** - -Run (after successful build): - -```bash -grep -n "peerTokenTitle(.*_asPeer())" submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -Expected: **0 matches**. - ---- - -## Task 5: Commit - -**Files:** -- `submodules/TelegramUI/Sources/ContactMultiselectionController.swift` - -- [ ] **Step 1: Stage the one file** - -Run: - -```bash -git add submodules/TelegramUI/Sources/ContactMultiselectionController.swift -``` - -- [ ] **Step 2: Verify the staged diff** - -Run: - -```bash -git diff --cached --stat -``` - -Expected: `1 file changed, 6 insertions(+), 6 deletions(-)` (or thereabouts — 1 line's worth of signature change, 1 body-line change, 5 identical call-site changes; each is a 1-line replacement, net zero line-count delta). - -Also run: - -```bash -git diff --cached -``` - -Inspect manually to confirm: (a) the function signature changed `peer: Peer` → `peer: EnginePeer`; (b) the body `EnginePeer(peer).displayTitle(...)` → `peer.displayTitle(...)`; (c) 5 call sites lost `._asPeer()`. No other edits. - -- [ ] **Step 3: Commit** - -Run: - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 37 - -peerTokenTitle: peer parameter Peer -> EnginePeer. - -Drops 5 _asPeer() bridges in ContactMultiselectionController.swift -(L171, L201, L386, L403, L748) - bridges installed by prior waves. - -Private free function, single-file change. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Confirm commit** - -Run: - -```bash -git log --oneline -3 -``` - -Expected: the new wave-37 commit at the top. - ---- - -## Task 6: Update memory / log - -**Files:** -- Modify: `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` -- Modify: `docs/superpowers/postbox-refactor-log.md` - -- [ ] **Step 1: Read the current memory file for the refactor** - -Read `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`. - -- [ ] **Step 2: Update frontmatter + add wave-37 entry** - -Update the `description:` frontmatter field to reference wave 37 outcome (number of bridges dropped, build-iteration count, first-pass-clean-or-not). Add a bullet to "Latest commits" section with the new SHA and a one-line summary. Remove the "peerTokenTitle parameter migration" bullet from the "Wave 37 candidates" section (it's now landed). Update "Recommended wave 37" section to "Recommended wave 38" with a fresh recommendation from the remaining candidates. - -- [ ] **Step 3: Read the refactor log** - -Read `docs/superpowers/postbox-refactor-log.md`, locate the "Wave 36 outcome" section. - -- [ ] **Step 4: Append wave-37 outcome** - -Under the "Wave N outcomes" section, append a "Wave 37 outcome" subsection with: - -- Commit SHA (from `git log --oneline -1`) -- File touched (1: ContactMultiselectionController.swift) -- Lines changed (6 deletions, 6 insertions) -- Bridges dropped (5) -- Build iterations to converge (should be 1) -- Any lessons observed (likely none — this wave is mechanical) - -- [ ] **Step 5: Commit memory + log update** - -Run: - -```bash -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 37 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file under `~/.claude/` is not in the repo — save it separately via the Write tool; do not try to `git add` it.) - ---- - -## Self-review results - -**Spec coverage:** Every scope item in the spec maps to a task: -- Spec L21 signature change → Task 2 Step 2 -- Spec L27 body simplification → Task 2 Step 2 -- Spec L171/201/386/403/748 bridge drops → Task 3 Steps 1–2 -- Spec verification (grep + build + post-grep) → Task 1 + Task 4 -- Spec commit message → Task 5 Step 3 - -Out-of-scope items (L459, `import Postbox`, `accountPeerId: PeerId`) remain explicitly untouched — no task edits them. - -**Placeholder scan:** No TBD, TODO, placeholder phrases, or "handle edge cases"-style hand-waves. Every step has a concrete command or code block. - -**Type consistency:** `peer: EnginePeer`, `EnginePeer.Id` (= `PeerId` typealias), and `EnginePeer.displayTitle(strings:displayOrder:)` are all consistent across tasks. diff --git a/docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md b/docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md deleted file mode 100644 index 9e29acda0c..0000000000 --- a/docs/superpowers/plans/2026-04-24-rcp-peers-engine-migration.md +++ /dev/null @@ -1,666 +0,0 @@ -# Wave 44 — RenderedChannelParticipant.peers Engine-Peer Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate `RenderedChannelParticipant.peers: [PeerId: Peer]` to `[EnginePeer.Id: EnginePeer]`. Closes the wave-41 ratchet — the public struct no longer leaks raw Postbox `Peer` in any field. - -**Architecture:** Single atomic commit. Declaration in TelegramCore changes; 8 TelegramCore producer functions wrap raw `Peer` values at their local-dict insertion points (inside transactions that already read from Postbox); 11 consumer-surface bridges drop (6 `EnginePeer(peer)` read-wraps + 5 `.mapValues({ $0._asPeer() })` constructor-unwrap transforms); 1 consumer-surface unwrap is added where an extracted `EnginePeer` value flows into a `SimpleDictionary`. - -**Tech Stack:** Swift, Bazel (via `python3 build-system/Make/Make.py`), Postbox, TelegramCore, TelegramEngine. No unit tests — full-build verification only. - -**Spec:** `docs/superpowers/specs/2026-04-24-rcp-peers-engine-migration-design.md` - ---- - -## File Structure - -All edits happen in existing files — no new files created. Touched files: - -**TelegramCore (declaration + producers, 9 files):** -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` (declaration) -- `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift` -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift` - -**Consumers (drops + 1 add, 5 files):** -- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` - -**Total:** 14 files, ~30 edits, one atomic commit. - ---- - -## Task 1: Pre-flight re-verification - -**Purpose:** Confirm the grep surface matches the spec before editing anything. If any site count diverges, stop and update the spec. - -**Files:** None modified. - -- [ ] **Step 1.1: Verify 7 `participant.peers[...]` consumer read sites** - -Run: -```bash -grep -rnE "participant\.peers\[|rcp\.peers\[|renderedParticipant\.peers\[" --include="*.swift" submodules/ 2>/dev/null -``` - -Expected output — exactly 6 bracketed-indexing sites (the 7th site, iteration without bracket-indexing, is checked in Step 1.2): -- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift:293` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:835` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:869` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:1087` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift:1121` -- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift:164` - -If any line numbers differ by more than ±3 lines, re-read surrounding context to confirm identity. If a NEW site appears that isn't in the spec, STOP and update the spec before proceeding. - -- [ ] **Step 1.2: Verify the iteration site is still at the expected line** - -Run: -```bash -grep -nE "for \(.*,.* peer\) in participant\.peers" submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift -``` - -Expected: `672: for (_, peer) in participant.peers {` - -- [ ] **Step 1.3: Verify all 8 TelegramCore producers still build `var peers: [PeerId: Peer] = [:]` locally** - -Run: -```bash -grep -rnE "^[[:space:]]+var peers: \[PeerId: Peer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null -``` - -Expected 8 matches, one per producer file: -- `Messages/RequestStartBot.swift:61` -- `Peers/ChannelOwnershipTransfer.swift:170` -- `Peers/JoinChannel.swift:59` -- `Peers/AddPeerMember.swift:242` -- `Peers/PeerAdmins.swift:251` -- `Peers/ChannelBlacklist.swift:128` -- `Peers/Ranks.swift:60` -- `Peers/ChannelMembers.swift:102` - -If a producer is missing from this grep, check whether it now receives `peers` as a parameter rather than building locally — if so, STOP and update the spec (chain-migration needed). - -- [ ] **Step 1.4: Verify no `as?` / `is TelegramX` casts exist on extracted dict values** - -Run: -```bash -grep -rnE "peer = participant\.peers" --include="*.swift" -A 4 submodules/ 2>/dev/null | grep -E "as\?|is Telegram" -``` - -Expected output: empty. If this returns non-empty, STOP and update the spec. - -- [ ] **Step 1.5: Verify no one is assigning into `participant.peers` (writes would break the migration)** - -Run: -```bash -grep -rnE "participant\.peers\[[^]]+\][[:space:]]*=" --include="*.swift" submodules/ 2>/dev/null -``` - -Expected output: empty (`.peers` is a `let`; no writes possible anyway, but double-check). - ---- - -## Task 2: Migrate declaration in ChannelParticipants.swift - -**Purpose:** Change the struct field type and init default. - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift:11, 14` - -- [ ] **Step 2.1: Change field declaration** - -In `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift`, line 11: - -```swift -// before - public let peers: [PeerId: Peer] - -// after - public let peers: [EnginePeer.Id: EnginePeer] -``` - -- [ ] **Step 2.2: Change init default** - -Same file, line 14: - -```swift -// before - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { - -// after - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [EnginePeer.Id: EnginePeer] = [:], presences: [PeerId: PeerPresence] = [:]) { -``` - -Do NOT commit yet — this leaves the repo in a broken state until producers and consumers are updated. - ---- - -## Task 3: Migrate TelegramCore producers (8 files) - -**Purpose:** Each of the 8 TelegramCore producers builds a local `peers: [PeerId: Peer] = [:]` dict from raw Postbox peers inside a transaction. Migrate each local dict to `[EnginePeer.Id: EnginePeer] = [:]` and wrap every insertion value with `EnginePeer(...)`. - -**Pattern (applies to every sub-step):** -```swift -// before -var peers: [PeerId: Peer] = [:] -peers[X.id] = X - -// after -var peers: [EnginePeer.Id: EnginePeer] = [:] -peers[X.id] = EnginePeer(X) -``` - -The surrounding `presences: [PeerId: PeerPresence]` dict and the `RCP(..., peer: EnginePeer(X), ...)` wrap on the primary `peer` field both stay unchanged. - -- [ ] **Step 3.1: Migrate `RequestStartBot.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift` - -Line 61: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 64: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.2: Migrate `ChannelOwnershipTransfer.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift` - -Line 170: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 172: `peers[accountUser.id] = accountUser` → `peers[accountUser.id] = EnginePeer(accountUser)` -Line 176: `peers[user.id] = user` → `peers[user.id] = EnginePeer(user)` - -Line 180 is a double-RCP-construction; `peers:` reuses the same local — no change at line 180. - -- [ ] **Step 3.3: Migrate `JoinChannel.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift` - -Line 59: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 64: `peers[account.peerId] = peer` → `peers[account.peerId] = EnginePeer(peer)` -Line 77: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.4: Migrate `AddPeerMember.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift` - -Line 242: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 244: `peers[memberPeer.id] = memberPeer` → `peers[memberPeer.id] = EnginePeer(memberPeer)` -Line 251: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.5: Migrate `PeerAdmins.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift` - -Line 251: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 253: `peers[adminPeer.id] = adminPeer` → `peers[adminPeer.id] = EnginePeer(adminPeer)` -Line 259: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.6: Migrate `ChannelBlacklist.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift` - -Line 128: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 130: `peers[memberPeer.id] = memberPeer` → `peers[memberPeer.id] = EnginePeer(memberPeer)` -Line 136: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.7: Migrate `Ranks.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift` - -Line 60: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 62: `peers[user.id] = user` → `peers[user.id] = EnginePeer(user)` -Line 68: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.8: Migrate `ChannelMembers.swift`** - -File: `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift` - -Line 102: `var peers: [PeerId: Peer] = [:]` → `var peers: [EnginePeer.Id: EnginePeer] = [:]` -Line 105: `peers[peer.id] = peer` → `peers[peer.id] = EnginePeer(peer)` - -- [ ] **Step 3.9: Post-producer verification** - -Run: -```bash -grep -rnE "^[[:space:]]+var peers: \[PeerId: Peer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null -``` - -Expected: no output (all 8 have been converted). - -Run: -```bash -grep -rnE "^[[:space:]]+var peers: \[EnginePeer\.Id: EnginePeer\] = \[:\]" submodules/TelegramCore/Sources/TelegramEngine/ 2>/dev/null | wc -l -``` - -Expected: `8` (or ` 8`). - ---- - -## Task 4: Drop 5 consumer `.mapValues({ $0._asPeer() })` transforms - -**Purpose:** These consumer-side constructors build a `[EnginePeer.Id: EnginePeer]` source dict locally and currently unwrap to `[PeerId: Peer]` via `.mapValues({ $0._asPeer() })` to feed the old constructor signature. After Task 2, the constructor expects engine values directly — the transform becomes a no-op and is removed. - -**Pattern (applies to every sub-step):** -```swift -// before -peers: peers.mapValues({ $0._asPeer() }) - -// after -peers: peers -``` - -- [ ] **Step 4.1: `ChannelAdminsController.swift:926`** - -File: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` - -Line 926 (long line): locate the substring `peers: peers.mapValues({ $0._asPeer() })` and replace with `peers: peers`. - -- [ ] **Step 4.2: `ChannelMembersSearchContainerNode.swift:994`** - -File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` - -Line 994: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`. - -- [ ] **Step 4.3: `ChannelMembersSearchContainerNode.swift:998`** - -Same file, line 998: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`. - -- [ ] **Step 4.4: `ChannelMembersSearchControllerNode.swift:409`** - -File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` - -Line 409: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`. - -- [ ] **Step 4.5: `ChannelMembersSearchControllerNode.swift:413`** - -Same file, line 413: replace `peers: peers.mapValues({ $0._asPeer() })` → `peers: peers`. - -- [ ] **Step 4.6: Post-Task-4 verification** - -Run: -```bash -grep -rnE "peers\.mapValues\(\{ \$0\._asPeer\(\) \}\)" --include="*.swift" submodules/ 2>/dev/null -``` - -Expected: no output (all 5 drops applied). If any remain, locate and drop. - ---- - -## Task 5: Drop 6 consumer `EnginePeer(peer).displayTitle(...)` read-wraps - -**Purpose:** Each site extracts `peer` from `participant.peers[X]`, wraps with `EnginePeer(peer)` to call `.displayTitle(...)`. After Task 2 the extracted `peer` is already `EnginePeer` — drop the wrap. - -**Pattern (applies to every sub-step):** -```swift -// before -EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...) - -// after -peer.displayTitle(strings: ..., displayOrder: ...) -``` - -- [ ] **Step 5.1: `ChannelAdminsController.swift:297`** - -File: `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift`, line 297. - -Replace: -```swift -peerText = strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string -``` -with: -```swift -peerText = strings.Channel_Management_PromotedBy(peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string -``` - -The adjacent `peer.id == participant.peer.id` comparison at line 294 stays unchanged (both are `EnginePeer.Id`). - -- [ ] **Step 5.2: `ChannelMembersSearchContainerNode.swift:839`** - -File: `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift`, line 839. - -Replace: -```swift -label = presentationData.strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` -with: -```swift -label = presentationData.strings.Channel_Management_PromotedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` - -- [ ] **Step 5.3: `ChannelMembersSearchContainerNode.swift:870`** - -Same file, line 870. - -Replace: -```swift -label = presentationData.strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` -with: -```swift -label = presentationData.strings.Channel_Management_RemovedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` - -- [ ] **Step 5.4: `ChannelMembersSearchContainerNode.swift:1091`** - -Same file, line 1091. - -Replace: -```swift -label = presentationData.strings.Channel_Management_PromotedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` -with: -```swift -label = presentationData.strings.Channel_Management_PromotedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` - -- [ ] **Step 5.5: `ChannelMembersSearchContainerNode.swift:1122`** - -Same file, line 1122. - -Replace: -```swift -label = presentationData.strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` -with: -```swift -label = presentationData.strings.Channel_Management_RemovedBy(peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)).string -``` - -- [ ] **Step 5.6: `ChannelBlacklistController.swift:165`** - -File: `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift`, line 165. - -Replace: -```swift -text = .text(strings.Channel_Management_RemovedBy(EnginePeer(peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string, .secondary) -``` -with: -```swift -text = .text(strings.Channel_Management_RemovedBy(peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder)).string, .secondary) -``` - -- [ ] **Step 5.7: Post-Task-5 verification** - -Run: -```bash -grep -rnE "EnginePeer\(peer\)\.displayTitle" --include="*.swift" submodules/PeerInfoUI/ 2>/dev/null -``` - -Expected: no output within PeerInfoUI. (Other modules may still have unrelated `EnginePeer(peer).displayTitle` usages on non-RCP-peers peers — those are out of scope.) - -Run specifically for the 6 migrated sites: -```bash -grep -n "EnginePeer(peer)\.displayTitle" submodules/PeerInfoUI/Sources/ChannelAdminsController.swift submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift 2>/dev/null -``` - -Expected: no output. - ---- - -## Task 6: Add 1 consumer unwrap at ChatRecentActionsHistoryTransition - -**Purpose:** The one site that iterates `participant.peers` and inserts values into a `SimpleDictionary` container. After Task 2, the iterated `peer` is `EnginePeer`; the outer container still expects raw `Peer`. Unwrap at the insertion site. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift:673` - -- [ ] **Step 6.1: Replace insertion line** - -In `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift`: - -Context (lines 672–674, unchanged outside line 673): -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer -} -``` - -After edit: -```swift -for (_, peer) in participant.peers { - peers[peer.id] = peer._asPeer() -} -``` - -- [ ] **Step 6.2: Spot-check nearby wave-41 unwrap (reference, no change)** - -Line 675 in the same function is `peers[participant.peer.id] = participant.peer._asPeer()` — a wave-41 artifact, unrelated to this wave. Leave unchanged. - ---- - -## Task 7: Full build verification - -**Purpose:** Verify the atomic change set compiles. Produces the ONLY real test signal for this wave. - -**Files:** None modified; this is a build run. - -- [ ] **Step 7.1: Run the full build** - -Run: -```bash -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -Expected: build succeeds. Look for `INFO: Build completed successfully` near the end. - -- [ ] **Step 7.2: If build fails — triage** - -Expected failure patterns (from wave-41 lesson, budget 2–3 iterations): - -1. **Missing producer wrap** — compiler error `cannot assign value of type 'Peer' to subscript of type 'EnginePeer'` (or similar) at a TelegramCore producer file → check that file's `var peers:` decl was converted AND all insertion RHS values are wrapped. -2. **Missed consumer site** — compiler error at a `.displayTitle` call on a raw Peer → find `EnginePeer(peer).displayTitle` site that Task 5 missed; drop the wrap. -3. **Mismatched mapValues drop** — `cannot convert value of type '[EnginePeer.Id: EnginePeer]' to expected argument type '[PeerId: Peer]'` → the spec's risk #3 triggered (a `.mapValues` site had a raw-Peer source after all); replace the drop with `peers.mapValues(EnginePeer.init)` at that site instead. -4. **New grep surface** — compiler complains about a site not in this plan → add it to the commit's scope; log it to the outcome doc. - -Apply fixes, re-run Step 7.1. Repeat up to 3 iterations before re-evaluating scope. - -- [ ] **Step 7.3: Post-build final grep audit** - -Run: -```bash -grep -rnE "participant\.peers\[[^]]+\]" --include="*.swift" submodules/ 2>/dev/null -``` - -Expected: the same 6 read sites as Step 1.1 (now without `EnginePeer(peer)` wraps). - -Run: -```bash -grep -rnE "peers\.mapValues\(\{ \$0\._asPeer\(\) \}\)" --include="*.swift" submodules/ 2>/dev/null -``` - -Expected: no output. - -Run: -```bash -grep -n "public let peers: \[" submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift -``` - -Expected: `11: public let peers: [EnginePeer.Id: EnginePeer]`. - ---- - -## Task 8: Atomic commit - -**Purpose:** Land all wave-44 edits in ONE commit. Explicitly enumerate files in `git add` (wave-39 lesson — re-confirmed in waves 41, 42, 43) to avoid pulling in the pre-existing working-tree WIP listed in the spec's risk section (`ListView.swift`, `ChatMessageTransitionNode.swift`, tulsi/, TgVoip/, libx264/). - -**Files:** Commits all 14 wave-44 files. - -- [ ] **Step 8.1: Confirm working-tree state** - -Run: -```bash -git status --short -``` - -Expected (pre-existing WIP, unchanged): -- ` m build-system/bazel-rules/sourcekit-bazel-bsp` -- ` M submodules/Display/Source/ListView.swift` (do NOT include) -- ` M submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift` (do NOT include) -- `?? build-system/tulsi/` (do NOT include) -- `?? submodules/TgVoip/` (do NOT include) -- `?? third-party/libx264/` (do NOT include) - -Plus the wave-44 modified files: -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift` -- ` M submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift` -- ` M submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` -- ` M submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` -- ` M submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` -- ` M submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` -- ` M submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` - -If the set of wave-44-modified files doesn't match exactly (extra or missing), STOP and investigate before committing. - -- [ ] **Step 8.2: Stage only wave-44 files** - -Run: -```bash -git add \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \ - submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift -``` - -- [ ] **Step 8.3: Verify staged set matches expected** - -Run: -```bash -git diff --cached --stat -``` - -Expected: exactly 14 files staged, all from the wave-44 list. If `ListView.swift`, `ChatMessageTransitionNode.swift`, `bazel-rules/sourcekit-bazel-bsp`, `tulsi/`, `TgVoip/`, or `libx264/` appear here, unstage them. - -- [ ] **Step 8.4: Commit** - -Run: -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 44 - -Migrate RenderedChannelParticipant.peers from [PeerId: Peer] to -[EnginePeer.Id: EnginePeer]. Closes the wave-41 ratchet — the public -struct no longer leaks raw Peer types in any field (presences stays -Postbox-typed; separate migration). - -Consumer-surface: -10 bridges. Dropped 6 EnginePeer(peer) read-wraps -at participant.peers[...] extraction sites across -ChannelAdminsController, ChannelMembersSearchContainerNode, -ChannelBlacklistController. Dropped 5 .mapValues({ $0._asPeer() }) -constructor-unwrap transforms in ChannelAdminsController, -ChannelMembersSearchContainerNode, ChannelMembersSearchControllerNode. -Added 1 ._asPeer() at ChatRecentActionsHistoryTransition.swift:673 -where the iterated value is inserted into a raw-Peer SimpleDictionary. - -TelegramCore producers: 8 files build the local peers dict inside -postbox.transaction and wrap at the insertion point. ChannelMembers, -RequestStartBot, ChannelOwnershipTransfer, JoinChannel, AddPeerMember, -PeerAdmins, ChannelBlacklist, Ranks. - -No unit tests in this project; full Telegram/Telegram build verified -under configuration=debug_sim_arm64. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 8.5: Verify commit** - -Run: -```bash -git log -1 --stat -``` - -Expected: commit with 14 files changed, message starting with `Postbox -> TelegramEngine wave 44`. - -Run: -```bash -git status --short -``` - -Expected: no M- or A-flagged wave-44 files (all committed); only the pre-existing WIP (`ListView.swift`, `ChatMessageTransitionNode.swift`, etc.) remains. - ---- - -## Rollback - -If the wave cannot be completed (e.g., build fails after 4+ iterations and the scope balloons beyond plan): - -```bash -git restore --staged \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \ - submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift - -git checkout -- \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \ - submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift -``` - -Then document what was learned in an outcome doc and update `project_postbox_refactor_next_wave.md`. - ---- - -## Success criteria (from spec) - -1. ✅ `ChannelParticipants.swift` has `peers: [EnginePeer.Id: EnginePeer]` declaration (Task 2). -2. ✅ All 8 TelegramCore producers compile with wrapped inserts (Task 3). -3. ✅ All 5 consumer `.mapValues({ $0._asPeer() })` transforms are removed (Task 4). -4. ✅ All 6 consumer `EnginePeer(peer).displayTitle(...)` wraps on extracted dict values are removed (Task 5). -5. ✅ `ChatRecentActionsHistoryTransition.swift:673` uses `peer._asPeer()` for the SimpleDictionary insertion value (Task 6). -6. ✅ Full `Telegram/Telegram` build (`configuration=debug_sim_arm64`) is clean — **one** atomic commit (Tasks 7, 8). -7. ✅ Grep post-migration: `participant.peers[` returns only engine-typed call sites; no residual `EnginePeer(peer)` on `.peers[...]` extractions (Steps 5.7, 7.3). diff --git a/docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md deleted file mode 100644 index 9ac710ea08..0000000000 --- a/docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md +++ /dev/null @@ -1,860 +0,0 @@ -# Wave 41 — `RenderedChannelParticipant.peer → EnginePeer` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate `TelegramCore.RenderedChannelParticipant.peer` from Postbox `Peer` to TelegramCore `EnginePeer`. Drop ~37 bridges (net ~−14 after adds) and eliminate 2 Shape-C ratchet wraps installed by wave 39. - -**Architecture:** Single atomic commit. One TelegramCore struct field change + 16 TelegramCore internal construction sites wrapped with `EnginePeer(peer)` + 17 consumer files updated: ZERO sites untouched (~160), ~32 DROP sites unwrapped, 9 CAST sites rewritten to pattern-match, 3 ADD-ASPEER sites append `._asPeer()`, 7 ADD-WRAP consumer constructors wrap raw `Peer` with `EnginePeer`. - -**Tech Stack:** Swift, Bazel (`Make.py` wrapper), TelegramCore, Postbox → TelegramEngine refactor conventions per `CLAUDE.md`. - -**Build command:** -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - ---- - -## File Structure - -**Created:** none. - -**Modified (27 files):** - -TelegramCore (10 files): -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` — struct field type + init param + Equatable impl -- `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift` — 7 constructor wraps -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift` — 2 constructor wraps -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift` — 1 constructor wrap -- `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift` — 1 constructor wrap - -PeerInfoUI (6 files): -- `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` -- `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` -- `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift` - -Other consumers (11 files): -- `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` -- `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift` -- `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` -- `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift` -- `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift` -- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift` -- `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift` -- `submodules/TemporaryCachedPeerDataManager/Sources/ChannelMemberCategoryListContext.swift` *(no `participant.peer` edits needed — all ZERO; file touched only if build surfaces type issues)* -- `submodules/TemporaryCachedPeerDataManager/Sources/PeerChannelMemberCategoriesContextsManager.swift` *(no edits expected — only `item.peer.id` reference is ZERO)* - ---- - -## Task 1: Migrate the struct definition - -**File:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift` - -- [ ] **Step 1.1: Edit struct field, init param, and Equatable impl** - -Replace the entire struct body: - -```swift -public struct RenderedChannelParticipant: Equatable { - public let participant: ChannelParticipant - public let peer: EnginePeer - public let peers: [PeerId: Peer] - public let presences: [PeerId: PeerPresence] - - public init(participant: ChannelParticipant, peer: EnginePeer, peers: [PeerId: Peer] = [:], presences: [PeerId: PeerPresence] = [:]) { - self.participant = participant - self.peer = peer - self.peers = peers - self.presences = presences - } - - public static func ==(lhs: RenderedChannelParticipant, rhs: RenderedChannelParticipant) -> Bool { - return lhs.participant == rhs.participant && lhs.peer == rhs.peer - } -} -``` - -Note: the file already imports both `Postbox` (for `Peer`/`PeerId`/`PeerPresence`) and TelegramCore internal symbols (`EnginePeer` visible from within the same module). No import changes needed. - ---- - -## Task 2: Wrap TelegramCore-internal constructor sites - -Each site receives a raw `Peer` and must now wrap it with `EnginePeer(peer)`. All edits are identical in shape. - -- [ ] **Step 2.1:** `submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift:65` - -Before: -```swift -return .channelParticipant(RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: presences)) -``` -After: -```swift -return .channelParticipant(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: presences)) -``` - -- [ ] **Step 2.2:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift:255` - -Before: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: memberPeer, peers: peers, presences: presences)) -``` -After: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(memberPeer), peers: peers, presences: presences)) -``` - -- [ ] **Step 2.3:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift` — 7 constructor wraps - -Line 271: -```swift -action = .participantInvite(RenderedChannelParticipant(participant: participant, peer: peer)) -// becomes: -action = .participantInvite(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer))) -``` - -Line 279 (two constructors on one line): -```swift -action = .participantToggleBan(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer)) -// becomes: -action = .participantToggleBan(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer))) -``` - -Line 287 (two constructors on one line): -```swift -action = .participantToggleAdmin(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer)) -// becomes: -action = .participantToggleAdmin(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer))) -``` - -Line 483 (two constructors on one line): -```swift -action = .participantSubscriptionExtended(prev: RenderedChannelParticipant(participant: prevParticipant, peer: prevPeer), new: RenderedChannelParticipant(participant: newParticipant, peer: newPeer)) -// becomes: -action = .participantSubscriptionExtended(prev: RenderedChannelParticipant(participant: prevParticipant, peer: EnginePeer(prevPeer)), new: RenderedChannelParticipant(participant: newParticipant, peer: EnginePeer(newPeer))) -``` - -- [ ] **Step 2.4:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift:140` - -Before: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: memberPeer, peers: peers, presences: presences), isMember) -``` -After: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(memberPeer), peers: peers, presences: presences), isMember) -``` - -- [ ] **Step 2.5:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift:115` - -Before: -```swift -items.append(RenderedChannelParticipant(participant: participant, peer: peer, peers: peers, presences: renderedPresences)) -``` -After: -```swift -items.append(RenderedChannelParticipant(participant: participant, peer: EnginePeer(peer), peers: peers, presences: renderedPresences)) -``` - -- [ ] **Step 2.6:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift:180` - -Before: -```swift -return [(currentCreator, RenderedChannelParticipant(participant: updatedPreviousCreator, peer: accountUser, peers: peers, presences: presences)), (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: user, peers: peers, presences: presences))] -``` -After: -```swift -return [(currentCreator, RenderedChannelParticipant(participant: updatedPreviousCreator, peer: EnginePeer(accountUser), peers: peers, presences: presences)), (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(user), peers: peers, presences: presences))] -``` - -- [ ] **Step 2.7:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift:82` - -Before: -```swift -return RenderedChannelParticipant(participant: updatedParticipant, peer: peer, peers: peers, presences: presences) -``` -After: -```swift -return RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(peer), peers: peers, presences: presences) -``` - -- [ ] **Step 2.8:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift:262` - -Before: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: adminPeer, peers: peers, presences: presences)) -``` -After: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(adminPeer), peers: peers, presences: presences)) -``` - -- [ ] **Step 2.9:** `submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift:95` - -Before: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: user, peers: peers, presences: presences)) -``` -After: -```swift -return (currentParticipant, RenderedChannelParticipant(participant: updatedParticipant, peer: EnginePeer(user), peers: peers, presences: presences)) -``` - ---- - -## Task 3: Consumer — PeerInfoUI/ChannelAdminsController.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelAdminsController.swift` - -- [ ] **Step 3.1:** Line 326 — DROP `EnginePeer(participant.peer)` wrap. - -Before: -```swift -return ItemListPeerItem(presentationData: presentationData, systemStyle: .glass, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, context: arguments.context, peer: EnginePeer(participant.peer), presence: participant.presences[participant.peer.id].flatMap { EnginePeer.Presence($0) }, text: peerText.isEmpty ? .presence : .text(peerText, .secondary), label: label, editing: editing, revealOptions: revealOptions, switchValue: nil, enabled: enabled, selectable: true, sectionId: self.section, action: action, setPeerIdWithRevealedOptions: { previousId, id in -``` -After: replace `peer: EnginePeer(participant.peer)` → `peer: participant.peer` (leave the rest of the line intact). - -- [ ] **Step 3.2:** Line 921 — DROP `._asPeer()` in constructor. - -Before: -```swift -result.append(RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: rank), peer: peer._asPeer(), presences: presences)) -``` -After: -```swift -result.append(RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: rank), peer: peer, presences: presences)) -``` -(`peer` here is already `EnginePeer` — confirmed by surrounding code where `creatorPeer: EnginePeer?` is assigned from this same loop variable.) - -- [ ] **Step 3.3:** Line 926 — DROP `._asPeer()` in constructor. - -Before: -```swift -result.append(RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: .internal_groupSpecific), promotedBy: creator.id, canBeEditedByAccountPeer: creator.id == context.account.peerId), banInfo: nil, rank: rank, subscriptionUntilDate: nil), peer: peer._asPeer(), peers: peers.mapValues({ $0._asPeer() }), presences: presences)) -``` -After: change `peer: peer._asPeer()` → `peer: peer`. Leave `peers.mapValues({ $0._asPeer() })` intact — `peers` field is unchanged. - ---- - -## Task 4: Consumer — PeerInfoUI/ChannelBlacklistController.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift` - -- [ ] **Step 4.1:** Line 170 (or 381 — the site installed by wave 39; the file has one site `EnginePeer(participant.peer)`) - -Before: -```swift -peer: EnginePeer(participant.peer) -``` -After: -```swift -peer: participant.peer -``` - -Note: the file may have a single such site; use: -``` -grep -n 'EnginePeer(participant\.peer)' submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift -``` -and DROP every match. - ---- - -## Task 5: Consumer — PeerInfoUI/ChannelMembersController.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelMembersController.swift` - -- [ ] **Step 5.1:** Line 305 — CAST rewrite. - -Before: -```swift -if let user = participant.peer as? TelegramUser, let _ = user.botInfo { -``` -After: -```swift -if case let .user(user) = participant.peer, let _ = user.botInfo { -``` - -- [ ] **Step 5.2:** Line 334 — DROP wrap. - -Before: -```swift -peer: EnginePeer(participant.peer) -``` -After: -```swift -peer: participant.peer -``` - -- [ ] **Step 5.3:** Line 707 — DROP wrap (the wave-39-installed Shape-C wrap). - -Before: -```swift -peer: EnginePeer(participant.peer) -``` -After: -```swift -peer: participant.peer -``` - ---- - -## Task 6: Consumer — PeerInfoUI/ChannelMembersSearchContainerNode.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift` - -This file has the most sites (4 CAST, 3 DROP pairs, 3 ADD-WRAP constructor sites). - -- [ ] **Step 6.1:** Line 212 — DROP two wraps on one line. - -Before: -```swift -peer: .peer(peer: EnginePeer(participant.peer), chatPeer: EnginePeer(participant.peer)), -``` -After: -```swift -peer: .peer(peer: participant.peer, chatPeer: participant.peer), -``` - -- [ ] **Step 6.2:** Line 223 — DROP wrap. - -Before: -```swift -interaction.peerSelected(EnginePeer(participant.peer), participant) -``` -After: -```swift -interaction.peerSelected(participant.peer, participant) -``` - -- [ ] **Step 6.3:** Line 752 — CAST rewrite. - -Before: -```swift -if excludeBots, let user = participant.peer as? TelegramUser, user.botInfo != nil { -``` -After: -```swift -if excludeBots, case let .user(user) = participant.peer, user.botInfo != nil { -``` - -- [ ] **Step 6.4:** Line 884 — CAST rewrite. Same pattern as 6.3. - -- [ ] **Step 6.5:** Line 987 — ADD-WRAP constructor. - -Before: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: peer) -``` -After: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: EnginePeer(peer)) -``` -(`peer` here is raw `Peer` from `peerView.peers[participant.peerId]` — confirmed by surrounding iteration code.) - -- [ ] **Step 6.6:** Line 994 — ADD-WRAP constructor. - -Change `peer: peer` to `peer: EnginePeer(peer)`. Full site for reference: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: TelegramChatAdminRightsFlags.peerSpecific(peer: .legacyGroup(group))), promotedBy: creatorPeer?.id ?? context.account.peerId, canBeEditedByAccountPeer: creatorPeer?.id == context.account.peerId), banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() })) -``` -Change only `peer: peer,` → `peer: EnginePeer(peer),`. - -- [ ] **Step 6.7:** Line 998 — ADD-WRAP constructor. - -```swift -renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() })) -``` -Change only `peer: peer,` → `peer: EnginePeer(peer),`. - -- [ ] **Step 6.8:** Line 1052 — CAST rewrite. Same pattern as 6.3. - -- [ ] **Step 6.9:** Line 1136 — CAST rewrite. Same pattern as 6.3. - ---- - -## Task 7: Consumer — PeerInfoUI/ChannelMembersSearchControllerNode.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift` - -- [ ] **Step 7.1:** Line 148 — DROP wrap. - -Before: -```swift -peer: EnginePeer(participant.peer) -``` -After: -```swift -peer: participant.peer -``` -(The line has the wrap appearing twice — search the file for `EnginePeer(participant.peer)` and drop each occurrence. Use Edit with `replace_all` if unambiguous.) - -- [ ] **Step 7.2:** Line 404 — ADD-WRAP constructor. - -Before: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: peer, presences: peerView.peerPresences) -``` -After: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .creator(id: peer.id, adminInfo: nil, rank: nil), peer: EnginePeer(peer), presences: peerView.peerPresences) -``` - -- [ ] **Step 7.3:** Line 409 — ADD-WRAP constructor. - -Change `peer: peer,` → `peer: EnginePeer(peer),` in the full line: -```swift -renderedParticipant = RenderedChannelParticipant(participant: .member(id: peer.id, invitedAt: 0, adminInfo: ChannelParticipantAdminInfo(rights: TelegramChatAdminRights(rights: TelegramChatAdminRightsFlags.peerSpecific(peer: EnginePeer(mainPeer))), promotedBy: creator.id, canBeEditedByAccountPeer: creator.id == context.account.peerId), banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer, peers: peers.mapValues({ $0._asPeer() }), presences: peerView.peerPresences) -``` - -- [ ] **Step 7.4:** Line 413 — ADD-WRAP constructor. Same `peer: peer,` → `peer: EnginePeer(peer),`. - -- [ ] **Step 7.5:** Line 516 — CAST rewrite. - -Before: -```swift -if let user = participant.peer as? TelegramUser, user.botInfo != nil { -``` -After: -```swift -if case let .user(user) = participant.peer, user.botInfo != nil { -``` - -- [ ] **Step 7.6:** Line 558 — CAST rewrite. Same pattern as 7.5. - ---- - -## Task 8: Consumer — PeerInfoUI/ChannelPermissionsController.swift - -**File:** `submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift` - -- [ ] **Step 8.1:** Lines 480 and 483 — DROP wraps. - -Both lines contain `EnginePeer(participant.peer)`. Change each to `participant.peer`. - -If the two occurrences are unambiguous, use Edit with `replace_all=true` on `EnginePeer(participant.peer)` → `participant.peer`. - ---- - -## Task 9: Consumer — SearchPeerMembers/SearchPeerMembers.swift - -**File:** `submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` - -- [ ] **Step 9.1:** Lines 30, 36, 61, 76 — DROP wraps. - -All four sites are `EnginePeer(participant.peer)`. Use Edit with `replace_all=true`: -- old: `EnginePeer(participant.peer)` -- new: `participant.peer` - -Verify with `grep -n 'EnginePeer(participant\.peer)' submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift` → should return empty after edit. - ---- - -## Task 10: Consumer — ChatRecentActionsController.swift - -**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` - -- [ ] **Step 10.1:** Line 359 — DROP wrap. - -Before: -```swift -EnginePeer(participant.peer) -``` -After: -```swift -participant.peer -``` - ---- - -## Task 11: Consumer — ChatRecentActionsFilterController.swift - -**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift` - -- [ ] **Step 11.1:** Line 217 — DROP wrap. - -Change `EnginePeer(participant.peer)` → `participant.peer` on line 217. - -- [ ] **Step 11.2:** Line 445 — ADD-WRAP constructor rewrite. - -Before: -```swift -if let peer = peer, case let .user(user) = peer { - return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: user) -} -``` -After: -```swift -if let peer = peer, case let .user(user) = peer { - return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: .user(user)) -} -``` -(`.user(user)` is the enum case `EnginePeer.user(TelegramUser)`. Alternative: `peer: EnginePeer(user)` or `peer: peer` — but `peer: peer` reuses the already-unwrapped EnginePeer and is the cleanest. Use `peer: peer`.) - -Preferred after: -```swift -if let peer = peer, case let .user(user) = peer { - return RenderedChannelParticipant(participant: .member(id: user.id, invitedAt: 0, adminInfo: nil, banInfo: nil, rank: nil, subscriptionUntilDate: nil), peer: peer) -} -``` - ---- - -## Task 12: Consumer — ChatRecentActionsHistoryTransition.swift - -**File:** `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift` - -This is the highest-volume consumer file (12 `EnginePeer(new.peer)` sites + 2 ADD-ASPEER sites). - -- [ ] **Step 12.1:** DROP all `EnginePeer(new.peer)` wraps. - -Use Edit with `replace_all=true`: -- old: `EnginePeer(new.peer)` -- new: `new.peer` - -After: grep `EnginePeer(new\.peer)` should return empty. - -- [ ] **Step 12.2:** Line 675 — ADD-ASPEER. - -Before: -```swift -peers[participant.peer.id] = participant.peer -``` -After: -```swift -peers[participant.peer.id] = participant.peer._asPeer() -``` -(Target dict is `SimpleDictionary`; the value side needs raw Peer.) - -- [ ] **Step 12.3:** Line 2275 — ADD-ASPEER. - -Before: -```swift -peers[new.peer.id] = new.peer -``` -After: -```swift -peers[new.peer.id] = new.peer._asPeer() -``` - ---- - -## Task 13: Consumer — PeerInfoMembers.swift - -**File:** `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift` - -- [ ] **Step 13.1:** Line 33 — ADD-ASPEER. - -Before: -```swift -var peer: Peer { - switch self { - case let .channelMember(participant, _): - return participant.peer -``` -After: -```swift -var peer: Peer { - switch self { - case let .channelMember(participant, _): - return participant.peer._asPeer() -``` - -No other edits in this file. The `participant.peer.id` accesses at lines 22, 44 are ZERO; `item.peer.id` at line 171 is ZERO. - ---- - -## Task 14: Consumer — ShareWithPeersScreenState.swift - -**File:** `submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift` - -- [ ] **Step 14.1:** Line 558 — DROP wrap. - -Before: -```swift -peers.append(EnginePeer(participant.peer)) -``` -After: -```swift -peers.append(participant.peer) -``` - -- [ ] **Step 14.2:** Line 566 — CAST rewrite. - -Before: -```swift -if let user = participant.peer as? TelegramUser, user.botInfo != nil { -``` -After: -```swift -if case let .user(user) = participant.peer, user.botInfo != nil { -``` - -- [ ] **Step 14.3:** Line 576 — DROP wrap. - -Before: -```swift -peers.append(EnginePeer(participant.peer)) -``` -After: -```swift -peers.append(participant.peer) -``` - ---- - -## Task 15: Consumer — AdminUserActionsSheet.swift - -**File:** `submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift` - -This file has ~6 `EnginePeer(peer.peer)` / `EnginePeer(component.peers[0].peer)` wraps and many ZERO sites. - -- [ ] **Step 15.1:** Use Edit with `replace_all=true`: -- old: `EnginePeer(peer.peer)` -- new: `peer.peer` - -This covers lines 284, 522, 523. - -- [ ] **Step 15.2:** Edit the `EnginePeer(component.peers[0].peer)` sites at lines 404, 416, 417. - -Use Edit with `replace_all=true`: -- old: `EnginePeer(component.peers[0].peer)` -- new: `component.peers[0].peer` - -- [ ] **Step 15.3:** Verify no other `EnginePeer(` wraps around `.peer` accesses remain on `RenderedChannelParticipant`. Run: -``` -grep -n 'EnginePeer(.*\.peer)' submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift -``` -Confirm remaining matches are on non-RCP types (e.g., some other context-derived peer). - ---- - -## Task 16: Consumer — StoryContentLiveChatComponent.swift - -**File:** `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift` - -- [ ] **Step 16.1:** Line 370 — DROP `._asPeer()` in constructor. - -Before: -```swift -peer: author._asPeer() -``` -After: -```swift -peer: author -``` -(`author` is `EnginePeer` — confirmed by the surrounding code that uses `author.id` and by the `chatPeer` signal's return type.) - ---- - -## Task 17: Consumer — ChatControllerAdminBanUsers.swift - -**File:** `submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift` - -- [ ] **Step 17.1:** Line 226 — ADD-WRAP constructor. - -Before: -```swift -let peer = author -renderedParticipants.append(RenderedChannelParticipant( - participant: participant, - peer: peer -)) -``` -After: -```swift -let peer = author -renderedParticipants.append(RenderedChannelParticipant( - participant: participant, - peer: EnginePeer(peer) -)) -``` -(Confirmed `author` is raw `Peer` via `presentMultiBanMessageOptions(... authors: [Peer], ...)` signature on line 45.) - -- [ ] **Step 17.2:** Line 372 — DROP `._asPeer()` in constructor. - -Before: -```swift -peer: authorPeer._asPeer() -``` -After: -```swift -peer: authorPeer -``` -(Confirmed `authorPeer` is `EnginePeer?` at line 327 via `engine.data.get(Peer.Peer(id:))` signal; already guard-unwrapped.) - -- [ ] **Step 17.3:** Line 757 — DROP `._asPeer()` in constructor. - -Same edit pattern as 17.2: `peer: authorPeer._asPeer()` → `peer: authorPeer`. - ---- - -## Task 18: Full build verification - -- [ ] **Step 18.1:** Run the full build with `--continueOnError`. - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build success. First-pass-clean is the goal (wave-39 pattern applies — classification is exact, migration is mechanical, no inference-bearing return types). - -If the build fails, expect errors only in files in this plan. Any error outside the plan's file list is either: -- a pre-existing unrelated WIP (e.g., `ChatMessageTransitionNode.swift`) — not a wave-41 issue -- a genuine miss in pre-flight classification — record which file, update the plan, and re-run - -For each error in wave-41 files: -1. Read the error -2. Classify: is it a shape we mis-identified (ZERO that's not actually transparent) or a new shape (dict subscript, function arg to a `Peer`-typed param, etc.)? -3. Apply the appropriate fix (`._asPeer()` if raw Peer needed; unwrap the wrap if EnginePeer needed) -4. Re-run the build - -Budget: 1–3 build iterations. - -- [ ] **Step 18.2:** Post-build grep verification. - -Run these greps and confirm they return only the expected residual matches: - -```sh -grep -rn 'EnginePeer(participant\.peer)' submodules/ --include='*.swift' | grep -v submodules/TelegramCore/ | grep -v submodules/Postbox/ -``` -Expected: empty. - -```sh -grep -rn 'EnginePeer(new\.peer)' submodules/ --include='*.swift' | grep -v submodules/TelegramCore/ -``` -Expected: empty. - -```sh -grep -rn 'participant\.peer as\? TelegramUser' submodules/ --include='*.swift' -``` -Expected: empty. - -```sh -grep -n 'public let peer:' submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift -``` -Expected: `public let peer: EnginePeer`. - ---- - -## Task 19: Commit - -- [ ] **Step 19.1:** Stage only wave-41 files (explicitly enumerate — wave-39 lesson). - -```sh -git status --short -``` - -Inspect the output. Only wave-41 files should appear as modified. If pre-existing WIP (e.g., `submodules/TelegramUI/Sources/ChatMessageTransitionNode.swift`) is also modified, do NOT include it in the commit. - -```sh -git add \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelParticipants.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Messages/RequestStartBot.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/AddPeerMember.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelAdminEventLogs.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelBlacklist.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelMembers.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/ChannelOwnershipTransfer.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/JoinChannel.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/PeerAdmins.swift \ - submodules/TelegramCore/Sources/TelegramEngine/Peers/Ranks.swift \ - submodules/PeerInfoUI/Sources/ChannelAdminsController.swift \ - submodules/PeerInfoUI/Sources/ChannelBlacklistController.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersController.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchContainerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelMembersSearchControllerNode.swift \ - submodules/PeerInfoUI/Sources/ChannelPermissionsController.swift \ - submodules/SearchPeerMembers/Sources/SearchPeerMembers.swift \ - submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsFilterController.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsHistoryTransition.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoMembers.swift \ - submodules/TelegramUI/Components/ShareWithPeersScreen/Sources/ShareWithPeersScreenState.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContentLiveChatComponent.swift \ - submodules/TelegramUI/Sources/ChatControllerAdminBanUsers.swift \ - docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md \ - docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md -``` - -(Add any additional files the build iterations surfaced.) - -Run `git status --short` and confirm only staged wave-41 files are green, and any unrelated WIP is still marked as unstaged. - -- [ ] **Step 19.2:** Commit. - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 41 - -Migrate RenderedChannelParticipant.peer from Postbox `Peer` to -TelegramCore `EnginePeer`. 27 files touched: 10 TelegramCore -(1 struct + 9 files with constructor wraps) + 17 consumer files. - -Drops the 2 Shape-C wraps installed by wave 39 (ChannelMembersController -and ChannelBlacklistController) plus ~37 additional EnginePeer(...) / -._asPeer() bridges across the consumer surface. Net ~-14 bridges -after the 16 TelegramCore-internal EnginePeer(peer) wraps and the 7 -consumer ADD-WRAP constructor sites. RCP.peers and RCP.presences -dictionaries remain Postbox-typed (deferred). -EOF -)" -``` - -- [ ] **Step 19.3:** Confirm commit landed and working tree is clean except for pre-existing WIP. - -```sh -git status --short -git log -1 --oneline -``` - ---- - -## Task 20: Log the wave outcome - -- [ ] **Step 20.1:** Append wave 41 entry to `docs/superpowers/postbox-refactor-log.md`. - -Format (matching prior wave entries): - -```markdown -## Wave 41 outcome — RenderedChannelParticipant.peer: Peer → EnginePeer (2026-04-24) - -Landed as commit ``. 27 files / ~45 site edits / net ~-14 bridges. - -**Shape distribution:** -- TelegramCore: 16 constructor sites wrapped with `EnginePeer(peer)` across 9 files + struct field migrated in ChannelParticipants.swift -- Consumers: ~32 DROP (EnginePeer/._asPeer unwraps), 9 CAST (as? TelegramUser → if case let .user), 3 ADD-ASPEER, 7 ADD-WRAP constructor sites - -**First-pass-clean:** . Extends wave-39 lesson: first-pass-clean -is achievable when classification is exact and all patterns are mechanical. - -**Ratchet economics:** drops 2 wave-39 Shape-C wraps -(ChannelMembersController:707, ChannelBlacklistController:381) and installs 7 ADD-WRAP -consumer constructor sites as ratchet markers for a future -`RenderedChannelParticipant.peers: [PeerId: Peer] → [EnginePeer.Id: EnginePeer]` wave. - -**Spec:** `docs/superpowers/specs/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration-design.md`. -**Plan:** `docs/superpowers/plans/2026-04-24-renderedchannelparticipant-peer-engine-peer-migration.md`. -``` - -- [ ] **Step 20.2:** Update the `project_postbox_refactor_next_wave.md` memory file with the wave 41 outcome and the wave 42 candidate (likely `PeerInfoScreenData.peer → EnginePeer`). - -- [ ] **Step 20.3:** Commit docs updates. - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 41 outcome -EOF -)" -``` diff --git a/docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md b/docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md deleted file mode 100644 index 56c804e215..0000000000 --- a/docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md +++ /dev/null @@ -1,666 +0,0 @@ -# Wave 35: `SendAsPeer.peer: Peer → EnginePeer` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the public field `SendAsPeer.peer` from the Postbox `Peer` protocol to the TelegramCore `EnginePeer` enum in a single atomic commit. Drops 3 `._asPeer()` bridges at construction sites, collapses 6 redundant `EnginePeer(peer.peer)` wraps, rewrites 1 `peer.peer as? TelegramChannel` downcast to an enum pattern, and adds `EnginePeer(channel)` wraps at 2 raw-`TelegramChannel` construction sites. No outflow `._asPeer()` bridges need to be added for this wave (unlike wave 34's `ContactListPeer.peer(peer:)` bridge). - -**Architecture:** One atomic commit. The field-type change is necessarily atomic (half-migrated SendAsPeer doesn't compile), so all edits land together. TelegramCore's `_internal_*SendAsAvailablePeers` functions keep `import Postbox` — only `SendAsPeer`'s public surface changes. No new wrappers, no new typealiases. The manual `==` body is replaced with synthesized Equatable (EnginePeer is Equatable). - -**Tech Stack:** Swift, Bazel build via Make.py wrapper. No tests — verification is build success + targeted grep checks. - -**Spec:** `docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md` - ---- - -## File Structure - -**Modified files (7 expected — 1 TelegramCore + 6 consumer. Plus 2 "verify no-edit" files.)** - -| File | Edit count | Category | -|---|---|---| -| `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift` | ~7 spot edits (struct change + 4 constructor wraps + drop manual `==`) | α | -| `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` | ~5 (1 cast rewrite + 4 wrap drops) | γ | -| `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` | 3 (1 bridge-drop + 2 EnginePeer wraps on raw channel) | δ | -| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift` | 1 (bridge-drop) | δ | -| `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` | 1 (wrap collapse) | δ | -| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` | ~4 (1 bridge-drop + 1 flatMap simplify + 1 map simplify) | δ | - -**Verify-only (no edits expected):** -| File | Reason | -|---|---| -| `submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift` | Holds `[SendAsPeer]?` at collection level, no `.peer` access. | -| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | Passes `currentSendAsPeer` through to `ChatSendAsPeerListContextItem` which keeps taking `[SendAsPeer]`. | - -**EnginePeer enum case mapping (used in cast rewrite):** - -| Postbox concrete | EnginePeer case | -|---|---| -| `TelegramChannel` | `.channel(TelegramChannel)` | -| `TelegramGroup` | `.legacyGroup(TelegramGroup)` | -| `TelegramUser` | `.user(TelegramUser)` | - ---- - -## Task 1: Edit `SendAsPeers.swift` — struct definition + constructor wraps - -**Files:** -- Modify: `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift` - -Foundational change. Without it, none of the consumer edits compile. - -- [ ] **Step 1.1: Update the SendAsPeer struct field, init parameter, and drop manual `==`** - -Edit: - -```swift -// OLD -public struct SendAsPeer: Equatable { - public let peer: Peer - public let subscribers: Int32? - public let isPremiumRequired: Bool - - public init(peer: Peer, subscribers: Int32?, isPremiumRequired: Bool) { - self.peer = peer - self.subscribers = subscribers - self.isPremiumRequired = isPremiumRequired - } - - public static func ==(lhs: SendAsPeer, rhs: SendAsPeer) -> Bool { - return lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers && lhs.isPremiumRequired == rhs.isPremiumRequired - } -} -``` - -```swift -// NEW -public struct SendAsPeer: Equatable { - public let peer: EnginePeer - public let subscribers: Int32? - public let isPremiumRequired: Bool - - public init(peer: EnginePeer, subscribers: Int32?, isPremiumRequired: Bool) { - self.peer = peer - self.subscribers = subscribers - self.isPremiumRequired = isPremiumRequired - } -} -``` - -Use the Edit tool with the OLD block as `old_string` and the NEW block as `new_string`. Swift synthesizes Equatable for structs where every stored property is Equatable: `EnginePeer` is Equatable, `Int32?` is Equatable, `Bool` is Equatable — so the manual `==` is no longer needed. - -- [ ] **Step 1.2: Wrap raw Postbox `Peer` values at the four constructor sites** - -Sites at lines 64, 170, 236, 330. Each binds a raw Postbox `Peer` (from `transaction.getPeer(peerId)` or `peers.map { ... }`) and passes it to the `SendAsPeer(peer: ...)` init. Wrap each with `EnginePeer(...)`. - -Edit (line 64, inside `_internal_cachedPeerSendAsAvailablePeers`, cache-hit branch): - -```swift -// OLD - peers.append(SendAsPeer(peer: peer, subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id))) -``` - -```swift -// NEW - peers.append(SendAsPeer(peer: EnginePeer(peer), subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id))) -``` - -Edit (line 170, inside `_internal_peerSendAsAvailablePeers`, network-response map): - -```swift -// OLD - return peers.map { SendAsPeer(peer: $0, subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) } -``` - -```swift -// NEW - return peers.map { SendAsPeer(peer: EnginePeer($0), subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) } -``` - -Edit (line 236, inside `_internal_cachedLiveStorySendAsAvailablePeers`, cache-hit branch): - -```swift -// OLD - peers.append(SendAsPeer(peer: peer, subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id))) -``` - -```swift -// NEW - peers.append(SendAsPeer(peer: EnginePeer(peer), subscribers: subscribers, isPremiumRequired: cached.premiumRequiredPeerIds.contains(peer.id))) -``` - -Note: lines 64 and 236 have identical text. If you prefer `replace_all=true`, do a grep first to confirm the count is exactly 2, then apply once. - -Edit (line 330, inside `_internal_liveStorySendAsAvailablePeers`, network-response map): - -```swift -// OLD - return peers.map { SendAsPeer(peer: $0, subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) } -``` - -```swift -// NEW - return peers.map { SendAsPeer(peer: EnginePeer($0), subscribers: subscribers[$0.id], isPremiumRequired: premiumRequiredPeerIds.contains($0.id)) } -``` - -Same remark as above: lines 170 and 330 are identical — one `replace_all=true` covers both if the count is exactly 2. - -- [ ] **Step 1.3: Verify** — read the updated file and confirm: - - The struct's `peer` field is now `EnginePeer` - - The init parameter is `peer: EnginePeer` - - Manual `==` has been removed - - All 4 constructor sites wrap with `EnginePeer(...)` - - `peer.peer.id` accesses inside the caching loops (lines 87, 90, 259, 262) remain unchanged (`EnginePeer.id` typealias to `PeerId` keeps them valid) - -Do not commit yet. - ---- - -## Task 2: Edit `ChatSendAsPeerListContextItem.swift` — cast rewrite + wrap collapse - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` - -1 Postbox-concrete downcast rewrite + 4 `EnginePeer(peer.peer)` wrap drops. - -- [ ] **Step 2.1: Rewrite the `peer.peer as? TelegramChannel` downcast at line 73** - -Edit: - -```swift -// OLD - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel { - if case .broadcast = peer.info { -``` - -```swift -// NEW - } else if let subscribers = peer.subscribers { - if case let .channel(channel) = peer.peer { - if case .broadcast = channel.info { -``` - -Note: the original `if let peer = peer.peer as? TelegramChannel` shadows the outer `peer: SendAsPeer` loop variable. The rewrite uses `channel` to avoid shadowing. Any subsequent uses of `peer.info`, `peer.flags`, etc. inside the inner `if let peer = ...` block must be renamed to `channel.*`. - -Read lines 70–90 before editing to see the full extent of the shadowed-`peer` scope, and ensure every reference to `peer.info` (and any sibling field access like `peer.flags`, `peer.username`, etc.) within the inner block is rewritten to `channel.*`. The snippet above captures the only `peer.info` site from the inventory. - -- [ ] **Step 2.2: Drop `EnginePeer(peer.peer)` wraps at lines 89, 110, 116, 121** - -The field `peer.peer` is now `EnginePeer`, so `EnginePeer(peer.peer)` becomes a type error. Drop the wrap. - -Read the full lines first to confirm each site's shape. Expected patterns (edit one at a time with enough surrounding context to make each unique — the four sites likely differ in surrounding tokens): - -For each of the four sites, the pattern to eliminate is `EnginePeer(peer.peer)` → `peer.peer`. Example: - -```swift -// OLD - let title = EnginePeer(peer.peer).displayTitle(strings: strings, displayOrder: nameDisplayOrder) -``` - -```swift -// NEW - let title = peer.peer.displayTitle(strings: strings, displayOrder: nameDisplayOrder) -``` - -Identify each of the four sites (lines 89, 110, 116, 121) by reading the file, then apply one Edit per site using enough surrounding context (usually 1–2 tokens before/after the `EnginePeer(peer.peer)` subexpression) to make the `old_string` unique. - -If all four lines reduce to the same substring pattern (e.g., `EnginePeer(peer.peer)` as a standalone subexpression), `replace_all=true` on the substring `EnginePeer(peer.peer)` → `peer.peer` is safe — but **first** grep to confirm the count is exactly 4 and no other meaning is captured. - -Run before: `grep -cE "EnginePeer\(peer\.peer\)" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` - -Expected: 4. - -- [ ] **Step 2.3: Verify** — grep: - -Run: `grep -nE "peer\.peer\s+(as\?|is)\s+Telegram|EnginePeer\(peer\.peer\)" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` - -Expected: zero matches. - ---- - -## Task 3: Edit `ChatControllerLoadDisplayNode.swift` — bridge-drop + raw-channel wraps - -**Files:** -- Modify: `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` - -1 `._asPeer()` bridge-drop at line 772 + 2 `EnginePeer(channel)` wraps for raw `TelegramChannel` at lines 805 and 823. - -- [ ] **Step 3.1: Bridge-drop at line 772** - -Edit: - -```swift -// OLD - return SendAsPeer(peer: peer._asPeer(), subscribers: nil, isPremiumRequired: false) -``` - -```swift -// NEW - return SendAsPeer(peer: peer, subscribers: nil, isPremiumRequired: false) -``` - -Verification: the surrounding signal chain binds `peer` as `EnginePeer` (from `context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: ...))`). The `._asPeer()` bridge is no longer needed. - -If the line text differs from the OLD block above (e.g., different field order or trailing arguments), read the file around line 772 and adjust the `old_string` to match byte-for-byte before editing. - -- [ ] **Step 3.2: Wrap raw `TelegramChannel` at line 805** - -Read lines 800–812 to see the bound `channel` variable. The construction site should be `SendAsPeer(peer: channel, ...)` where `channel: TelegramChannel` is raw Postbox. - -Edit: - -```swift -// OLD - SendAsPeer(peer: channel, subscribers: subscribers, isPremiumRequired: isPremiumRequired) -``` - -```swift -// NEW - SendAsPeer(peer: EnginePeer(channel), subscribers: subscribers, isPremiumRequired: isPremiumRequired) -``` - -If the surrounding context differs (different field values), match the actual line text when writing `old_string`. - -- [ ] **Step 3.3: Wrap raw `TelegramChannel` at line 823** - -Same pattern as Step 3.2. Read lines 818–830 first, identify the `SendAsPeer(peer: channel, ...)` construction site, and wrap `channel` with `EnginePeer(...)`. - -If the line text at 805 and 823 is identical, `replace_all=true` on the substring `SendAsPeer(peer: channel,` → `SendAsPeer(peer: EnginePeer(channel),` covers both. **First** grep to confirm the count: - -Run before: `grep -cE "SendAsPeer\(peer: channel," submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` - -Expected: 2. - -- [ ] **Step 3.4: Verify** — grep: - -Run: `grep -nE "SendAsPeer\(peer:\s+\w+\._asPeer\(\)|SendAsPeer\(peer:\s+channel," submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` - -Expected: zero matches. Lines 792, 826, 835, 844 retaining `.peer.id` accesses are expected and correct. - ---- - -## Task 4: Edit `ChatTextInputPanelComponent.swift` — bridge-drop - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift` - -1 `._asPeer()` bridge-drop. - -- [ ] **Step 4.1: Bridge-drop at line 847** - -Read lines 843–853 to confirm the surrounding signal chain and the type of `sendAsConfiguration.currentPeer` (expected: `EnginePeer`). - -Edit: - -```swift -// OLD - let sendAsPeers = [SendAsPeer(peer: sendAsConfiguration.currentPeer._asPeer(), subscribers: nil, isPremiumRequired: false)] -``` - -```swift -// NEW - let sendAsPeers = [SendAsPeer(peer: sendAsConfiguration.currentPeer, subscribers: nil, isPremiumRequired: false)] -``` - -If the actual line text wraps across multiple lines or uses different field values, match the real text byte-for-byte when writing `old_string`. - -- [ ] **Step 4.2: Verify** — grep: - -Run: `grep -nE "SendAsPeer\(peer:.*\._asPeer\(\)" submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift` - -Expected: zero matches. - ---- - -## Task 5: Edit `ChatTextInputPanelNode.swift` — wrap collapse - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` - -1 `EnginePeer(peer)` wrap collapse at line 1625. - -- [ ] **Step 5.1: Collapse `EnginePeer(peer)` wrap** - -Read lines 1615–1630 to see the full context. `peer` is bound from a preceding `var currentPeer = sendAsPeers.first(where: { $0.peer.id == ... })?.peer` (lines 1620–1622). After migration, `.peer` returns `EnginePeer`, so `EnginePeer(peer)` on an `EnginePeer` is a type error. - -Exact edit depends on the actual line text. Example shape: - -```swift -// OLD (at or near line 1625) - let enginePeer = EnginePeer(peer) -``` - -```swift -// NEW - let enginePeer = peer -``` - -Read lines 1623–1628 first and write the Edit with byte-accurate `old_string`. If the bound variable is then used as `enginePeer.displayTitle(...)`, consider whether the rename can be eliminated entirely (e.g., rename `peer` uses downstream), but prefer the minimal edit for commit clarity. - -Lines 1616, 1620, 1622, 2948, 5370 should remain unchanged — they perform `.peer.id` comparisons or `.first(where:)` lookups that work identically on `[SendAsPeer]` with `EnginePeer`-typed `.peer`. - -- [ ] **Step 5.2: Verify** — grep: - -Run: `grep -nE "EnginePeer\(peer\)" submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` - -Expected: zero matches. If any remain, inspect each — they may be unrelated wraps on non-SendAsPeer-sourced `peer` variables (in which case they must stay). - ---- - -## Task 6: Edit `StoryItemSetContainerViewSendMessage.swift` — multi-site cleanup - -**Files:** -- Modify: `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` - -1 bridge-drop + 1 flatMap simplify + 1 map simplify. Many other `.peer.id` / `.peer` accesses remain unchanged. - -- [ ] **Step 6.1: Bridge-drop at line 249** - -Read lines 244–254 to confirm `accountPeer` is typed as `EnginePeer` upstream. - -Edit: - -```swift -// OLD - availablePeers.append(SendAsPeer( - peer: accountPeer._asPeer(), - subscribers: nil, - isPremiumRequired: false - )) -``` - -```swift -// NEW - availablePeers.append(SendAsPeer( - peer: accountPeer, - subscribers: nil, - isPremiumRequired: false - )) -``` - -If the actual layout (whitespace, line breaks) differs from the OLD block, match the real text byte-for-byte when writing `old_string`. - -- [ ] **Step 6.2: Simplify flatMap at line 4080** - -`EnginePeer.init` as a function reference expects a raw `Peer` and returns `EnginePeer`. After migration, `sendAsPeer?.peer` is already `EnginePeer?`, so `.flatMap(EnginePeer.init)` is both unnecessary and a type error. - -Edit: - -```swift -// OLD - myPeer: (sendAsPeer?.peer).flatMap(EnginePeer.init), -``` - -```swift -// NEW - myPeer: sendAsPeer?.peer, -``` - -Read lines 4078–4082 first to confirm the surrounding labeled-argument layout and match byte-for-byte. - -- [ ] **Step 6.3: Simplify map at line 4081** - -`.map({ EnginePeer($0.peer) })` wraps each already-`EnginePeer` value in `EnginePeer(...)` — a type error. Drop the wrap. - -Edit: - -```swift -// OLD - availableSendAsPeers: component.isEmbeddedInCamera ? [] : (self.sendAsData?.availablePeers.map({ EnginePeer($0.peer) }) ?? []), -``` - -```swift -// NEW - availableSendAsPeers: component.isEmbeddedInCamera ? [] : (self.sendAsData?.availablePeers.map({ $0.peer }) ?? []), -``` - -Read lines 4079–4083 first to confirm the exact line text. - -- [ ] **Step 6.4: Verify** — grep: - -Run: `grep -nE "SendAsPeer\(peer:.*\._asPeer\(\)|EnginePeer\(\$0\.peer\)|\(sendAsPeer\?\.peer\)\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` - -Expected: zero matches. - -Retained-as-is accesses (inventory-verified correct after migration): `.peer.id` at lines 254, 688, 4088, 4089, 4327, 4333, 4340, 4356, 4372; optional chaining at 4050, 4068, 4069. These should NOT be edited. - ---- - -## Task 7: Verify "no-edit" consumer files - -**Files:** -- Read: `submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift` -- Read: `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` - -Sanity-check: confirm neither file contains `.peer as?`/`is`, `EnginePeer(.peer)`, or `._asPeer()` patterns tied to SendAsPeer. If any such pattern is found, fold the fix into the relevant task above before the build pass. - -- [ ] **Step 7.1: Grep ChatPresentationInterfaceState.swift** - -Run: `grep -nE "SendAsPeer|sendAsPeers" submodules/ChatPresentationInterfaceState/Sources/ChatPresentationInterfaceState.swift` - -Expected shape: field declaration, init param, assignment, equality comparison, `updatedSendAsPeers(_:)` method — all at the `[SendAsPeer]?` collection level. No `.peer` field access. - -- [ ] **Step 7.2: Grep StoryItemSetContainerComponent.swift** - -Run: `grep -nE "SendAsPeer|currentSendAsPeer|\.peer\b" submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift | grep -iE "sendAsPeer|\.peer"` - -Read lines 3056–3072 to confirm `sendMessageContext.currentSendAsPeer` is only passed through to `ChatSendAsPeerListContextItem` (which keeps `[SendAsPeer]`) or accessed for `.peer.id` comparisons — neither requires an edit. - -If the verification shows an edit is needed, add the edit as an additional step under the relevant Task 2–6. Do not edit here silently. - ---- - -## Task 8: Build verification (first pass) - -- [ ] **Step 8.1: Run the full build with `--continueOnError`** - -Run: - -```bash -source ~/.zshrc 2>/dev/null && python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError 2>&1 | tee /tmp/wave35-build.log -``` - -Expected outcome: ideally clean. Realistic outcome: 0–5 errors at sites the inventory missed. - -- [ ] **Step 8.2: Triage build errors** - -Likely error patterns and their fixes: - -| Error | Fix | -|---|---| -| `cannot convert value of type 'EnginePeer' to expected argument type 'Peer'` at site passing `peer.peer` | Add `._asPeer()` bridge: `peer.peer._asPeer()` | -| `cannot convert value of type 'Peer' to expected argument type 'EnginePeer'` at `SendAsPeer(peer: ...)` | Add wrap: `SendAsPeer(peer: EnginePeer(), ...)` | -| `value of type 'EnginePeer' has no member 'isEqual'` | Replace with `==` | -| `pattern of type 'TelegramChannel' cannot match values of type 'EnginePeer'` | Missed C2 — rewrite to `if case .channel(let channel) = peer.peer` form | -| `cannot invoke initializer for type 'EnginePeer' with an argument list of type '(EnginePeer)'` | Missed wrap collapse — drop `EnginePeer(...)` | -| `extraneous argument label 'peer:' in call` or similar on `SendAsPeer(...)` | Check that the construction arg is `EnginePeer`, not raw — add `EnginePeer(...)` wrap | - -For each error, identify the file:line, apply the appropriate fix, and re-run the build until clean. - -- [ ] **Step 8.3: Iterate to clean build** - -Re-run the build after each batch of fixes. The wave is complete when the build returns 0 errors for the targeted configuration. - -If 10+ unexpected errors surface, halt and reassess: the inventory was significantly incomplete and the wave may need to be split into pre-cleanup commits. Discuss with user before continuing. - ---- - -## Task 9: Post-build grep validations - -- [ ] **Step 9.1: Bridge-drop validation** - -Run: - -```bash -grep -rn "SendAsPeer(peer:.*\._asPeer()" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/" | grep -v "^submodules/Postbox/" -``` - -Expected: zero hits. If any remain, those are missed bridge-drops — fix and re-run Task 8. - -- [ ] **Step 9.2: Wrap-collapse validation** - -Run: - -```bash -for f in submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift \ - submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift \ - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift \ - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift; do - echo "=== $f ===" - grep -nE "EnginePeer\(peer\.peer\)|EnginePeer\(\$0\.peer\)|\(sendAsPeer\?\.peer\)\.flatMap\(EnginePeer\.init\)" "$f" -done -``` - -Expected: zero hits across all 5 files. - -- [ ] **Step 9.3: C2 cast validation** - -Run: - -```bash -grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift -``` - -Expected: zero hits. - -- [ ] **Step 9.4: Construction-site validation** - -Ensure all `SendAsPeer(peer: ...)` construction sites outside TelegramCore provide `EnginePeer`: - -```bash -grep -rnE "SendAsPeer\(peer:" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/" -``` - -Inspect each hit. Expected forms: `SendAsPeer(peer: , ...)` or `SendAsPeer(peer: EnginePeer(), ...)`. Anything of the form `SendAsPeer(peer: , ...)` is a miss — fix. - -If any of the validations fail, return to Task 8 to fix. - ---- - -## Task 10: Atomic commit + memory + log update - -- [ ] **Step 10.1: Stage and review** - -Run: - -```bash -git status --short -git diff --stat -``` - -Confirm exactly 6 modified Swift files (1 TelegramCore + 5 consumer — or 7 if Task 7 surfaced a needed edit). Files expected: -- `submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift` -- `submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift` -- `submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift` -- `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift` -- `submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift` -- `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` - -WIP from earlier (`build-system/bazel-rules/sourcekit-bazel-bsp`, `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/`) should NOT be staged. - -The `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md` untracked file should ALSO remain unstaged. - -- [ ] **Step 10.2: Stage only the wave-35 files** - -Run: - -```bash -git add submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift \ - submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift \ - submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift \ - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift \ - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift -``` - -If Task 7 surfaced an additional file, append it here. - -- [ ] **Step 10.3: Commit** - -Run: - -```bash -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 35: SendAsPeer.peer Peer -> EnginePeer - -Migrates the public field `SendAsPeer.peer` from the Postbox `Peer` -protocol to the TelegramCore `EnginePeer` enum. Internal -`_internal_*SendAsAvailablePeers` bodies keep `import Postbox` (they still -call `postbox.transaction`) and wrap raw peer values with `EnginePeer(peer)` -at the SendAsPeer constructor sites. Manual `==` body dropped in favor of -synthesized Equatable. - -Consumer-side cascade in 5 files: - - 3 `._asPeer()` bridge-drops at SendAsPeer constructor sites - - 6 redundant `EnginePeer(peer.peer)` / `EnginePeer($0.peer)` wrap - drops (the field is now EnginePeer, so the wrap fails to compile) - - 1 `peer.peer as? TelegramChannel` downcast rewritten to - `if case let .channel(channel) = peer.peer` enum-pattern form - - 2 `EnginePeer(channel)` wraps added where raw `TelegramChannel` is - passed into `SendAsPeer(peer: ...)` - - 1 `(sendAsPeer?.peer).flatMap(EnginePeer.init)` simplified to - `sendAsPeer?.peer` (already `EnginePeer?`) - -Files modified: - submodules/TelegramCore/Sources/TelegramEngine/Messages/SendAsPeers.swift - submodules/TelegramUI/Components/Chat/ChatSendAsContextMenu/Sources/ChatSendAsPeerListContextItem.swift - submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelComponent.swift - submodules/TelegramUI/Components/Chat/ChatTextInputPanelNode/Sources/ChatTextInputPanelNode.swift - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift - -Plan: docs/superpowers/plans/2026-04-24-sendaspeer-engine-peer-migration.md -Spec: docs/superpowers/specs/2026-04-24-sendaspeer-engine-peer-migration-design.md - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 10.4: Update CLAUDE.md wave counter** - -Edit `CLAUDE.md` to bump the "Waves landed so far" line from "34 waves" to "35 waves" and update the "as of" date if the commit lands after 2026-04-24. - -- [ ] **Step 10.5: Append wave outcome to the postbox-refactor-log** - -Append a "Wave 35 outcome" section to `docs/superpowers/postbox-refactor-log.md` documenting: -- Actual files touched and edit counts vs. plan -- Any inventory undercounts surfaced by Task 8 -- Any lessons learned (e.g., whether the flatMap/map simplifications were actually type-required or whether they could have been left as redundant-but-compiling wraps) - -Keep concise. - -- [ ] **Step 10.6: Commit the docs update** - -Run: - -```bash -git add CLAUDE.md docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: add wave 35 outcome (SendAsPeer.peer Peer→EnginePeer) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 10.7: Update the next-wave memory** - -Update `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Add wave 35 to the "Latest commits" section -- Move SendAsPeer migration from "Wave 34+ candidates → Downstream Peer-typed APIs" to landed -- Record the inventory undercount ratio (actual-files-touched ÷ pre-flight-file-count) for calibration of future Peer-typed-API waves -- Update the "Recommended wave 35" section to reflect the new wave 36 recommendation. Candidates to promote: `makePeerInfoController` (largest Peer-typed-API remaining), `ContactListPeer.peer(peer:)` case payload, `canSendMessagesToPeer(_:)` parameter, accountManager-side engine path, Shape-C resourceData module pick - -Use the Edit tool on the memory file. No git commit needed (memory lives outside the repo). - ---- - -## Risks and notes - -- **Inner `peer` shadowing in ChatSendAsPeerListContextItem:73.** The original `if let peer = peer.peer as? TelegramChannel` shadows the outer `peer: SendAsPeer` loop variable. The rewrite uses `channel` to avoid shadowing. Verify every reference to `peer.info` (and any sibling field access) within the old inner-if scope is updated to `channel.*` — Step 2.1's instructions cover this, but it's easy to miss a field reference. -- **`replace_all` correctness.** Whenever the plan suggests `replace_all=true`, verify the count first via grep. If the count is unexpected, revert to per-site Edits with surrounding context. -- **Inventory undercount.** Wave 34 undercounted by ~30%. The Explore agent for wave 35 explicitly included `.peer as?`/`is`/outflow-helper patterns, so the expected ratio is lower, but budget for 1–3 inventory-missed sites surfacing in Task 8. -- **Name collisions (do NOT touch).** `[EnginePeer]` arrays in `LiveStreamSettingsScreen.swift`, `ShareWithPeersScreen.swift`, and `ChatSendStarsScreen.swift` named `sendAsPeers` / `availableSendAsPeers` are unrelated. `ChatPanelInterfaceInteraction` callbacks named `openSendAsPeer` take `(ASDisplayNode, ContextGesture?)`, not `SendAsPeer`. `initialSendAsPeerId` parameters are `PeerId`-typed. If Task 8 surfaces errors in any of these files, the fix likely indicates a wrong cascade from a real SendAsPeer site — do NOT migrate those files as part of this wave. -- **WIP isolation.** Pre-existing modifications to `ChatListFilterPresetController.swift`, `ChatListFilterPresetListController.swift`, the `sourcekit-bazel-bsp` submodule marker, and untracked `build-system/tulsi/` / `submodules/TgVoip/` / `third-party/libx264/` / `docs/superpowers/plans/2026-04-22-claude-md-reorganization.md` are user WIP — do NOT stage them. Use the explicit `git add ` form in Step 10.2. diff --git a/docs/superpowers/plans/2026-04-25-clearpeerhistory-openclearhistory-engine-peer.md b/docs/superpowers/plans/2026-04-25-clearpeerhistory-openclearhistory-engine-peer.md deleted file mode 100644 index 1b847bd2d5..0000000000 --- a/docs/superpowers/plans/2026-04-25-clearpeerhistory-openclearhistory-engine-peer.md +++ /dev/null @@ -1,116 +0,0 @@ -# Wave 54: ClearPeerHistory.init + openClearHistory `chatPeer: Peer → EnginePeer` - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Migrate the `chatPeer:` parameter type on both `ClearPeerHistory.init` and `openClearHistory` from `Peer` to `EnginePeer`. Closes wave-53's deferred sibling. - -**Wave shape:** Bundled method-signature migration (familiar from waves 41/44/47/50/53). Mechanical `as?`/`is` cluster on a single field, with EnginePeer.init boundary lifts at each call site. - -**Tech Stack:** Swift, Bazel, Make.py. - ---- - -## Pre-Flight Inventory (validated 2026-04-25) - -**2 files modified, 16 edits.** - -### File 1: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` (PIS) - -| Line | Current | After | Note | -|---|---|---|---| -| 3213 | `func openClearHistory(... peer: Peer, chatPeer: Peer) {` | `... chatPeer: EnginePeer)` | type-site | -| 3230 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap | -| 3232 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap | -| 3251 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap | -| 3269 | `EnginePeer(chatPeer).compactDisplayTitle` | `chatPeer.compactDisplayTitle` | drop wrap | -| 7416 | `init(... peer: Peer, chatPeer: Peer, cachedData: ...)` | `... chatPeer: EnginePeer, cachedData: ...` | type-site | -| 7421 | `} else if chatPeer is TelegramSecretChat {` | `} else if case .secretChat = chatPeer {` | conversion | -| 7425 | `} else if let group = chatPeer as? TelegramGroup {` | `} else if case let .legacyGroup(group) = chatPeer {` | conversion | -| 7436 | `} else if let channel = chatPeer as? TelegramChannel {` | `} else if case let .channel(channel) = chatPeer {` | conversion | -| 7464 | `if let user = chatPeer as? TelegramUser, user.botInfo != nil {` | `if case let .user(user) = chatPeer, user.botInfo != nil {` | conversion | - -`peer:` parameter stays Peer-typed in both functions: `openClearHistory` doesn't reference `peer` in its body; `ClearPeerHistory.init` uses only `peer.id == context.account.peerId` (line 7417), which works on Peer (and would also work on EnginePeer, but migrating it would require 6 boundary lifts at PISPBA call sites for no internal benefit). - -### File 2: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenPerformButtonAction.swift` (PISPBA) - -| Line | Current | After | Note | -|---|---|---|---| -| 851 | `chatPeer: chatPeer._asPeer()` | `chatPeer: chatPeer` | drop wave-53 ADD | -| 857 | `chatPeer: user` | `chatPeer: EnginePeer(user)` | boundary lift (TelegramUser) | -| 1067 | `chatPeer: channel` | `chatPeer: EnginePeer(channel)` | boundary lift (TelegramChannel) | -| 1073 | `chatPeer: channel` | `chatPeer: EnginePeer(channel)` | boundary lift (TelegramChannel) | -| 1234 | `chatPeer: group` | `chatPeer: EnginePeer(group)` | boundary lift (TelegramGroup) | -| 1240 | `chatPeer: group` | `chatPeer: EnginePeer(group)` | boundary lift (TelegramGroup) | - -### Net accounting - -- **Drops:** 5 (4 `EnginePeer(chatPeer).compactDisplayTitle` + 1 `_asPeer()` bridge from wave 53). -- **Adds:** 5 boundary lifts (5 `EnginePeer(...)` wraps at PISPBA call sites). -- **Conversions:** 4 (`is`/`as?` → `case let`). -- **Type-site:** 2 (signature changes on PIS:3213 and PIS:7416). - -Net internal-bridge progress: `5 drops − 5 adds = 0 raw count`. But ratchet kills 4 internal display-call wraps (`EnginePeer(chatPeer).compactDisplayTitle` patterns) which is the hot path; only call-site boundary lifts remain. Closes wave-53's deferred ADD at PISPBA:851. - ---- - -## Tasks - -### Task 1: PIS signature edits + body wrap drops - -- [ ] **Step 1: Edit `openClearHistory` signature at PIS:3213** - -Replace `peer: Peer, chatPeer: Peer)` with `peer: Peer, chatPeer: EnginePeer)`. - -- [ ] **Step 2: Drop 4 `EnginePeer(chatPeer).compactDisplayTitle` wraps** - -`replace_all=true` of `EnginePeer(chatPeer).compactDisplayTitle` → `chatPeer.compactDisplayTitle`. (Only 4 occurrences in the file, all in `openClearHistory` body; verified by grep.) - -- [ ] **Step 3: Edit `ClearPeerHistory.init` signature at PIS:7416** - -Replace `peer: Peer, chatPeer: Peer, cachedData:` with `peer: Peer, chatPeer: EnginePeer, cachedData:`. - -- [ ] **Step 4: Convert PIS:7421 `is TelegramSecretChat`** - -Replace `} else if chatPeer is TelegramSecretChat {` with `} else if case .secretChat = chatPeer {`. - -- [ ] **Step 5: Convert PIS:7425 `as? TelegramGroup`** - -Replace `} else if let group = chatPeer as? TelegramGroup {` with `} else if case let .legacyGroup(group) = chatPeer {`. - -- [ ] **Step 6: Convert PIS:7436 `as? TelegramChannel`** - -Replace `} else if let channel = chatPeer as? TelegramChannel {` with `} else if case let .channel(channel) = chatPeer {`. - -- [ ] **Step 7: Convert PIS:7464 `as? TelegramUser`** - -Replace `if let user = chatPeer as? TelegramUser, user.botInfo != nil {` with `if case let .user(user) = chatPeer, user.botInfo != nil {`. - -### Task 2: PISPBA call-site lifts + bridge drop - -- [ ] **Step 1: Drop wave-53 `_asPeer()` bridge at PISPBA:851** - -Replace `chatPeer: chatPeer._asPeer()` with `chatPeer: chatPeer`. - -- [ ] **Step 2: Lift PISPBA:857 `chatPeer: user`** - -Replace `peer: user, chatPeer: user)` with `peer: user, chatPeer: EnginePeer(user))`. - -- [ ] **Step 3: Lift channel call sites (PISPBA:1067 + 1073)** - -`replace_all=true` of `chatPeer: channel` → `chatPeer: EnginePeer(channel)`. Verify exactly 2 hits flipped. - -- [ ] **Step 4: Lift group call sites (PISPBA:1234 + 1240)** - -`replace_all=true` of `chatPeer: group` → `chatPeer: EnginePeer(group)`. Verify exactly 2 hits flipped. - -### Task 3: Build verification - -- [ ] **Step 1: Run full build with `--continueOnError`.** - -Forecast 1 iteration. Risk: hidden `chatPeer` access on Peer-typed shape elsewhere (none expected — body audit complete). - -### Task 4: Commit + log - -- [ ] **Step 1: Commit wave with the two file paths explicitly.** -- [ ] **Step 2: Update `docs/superpowers/postbox-refactor-log.md` and the memory file.** -- [ ] **Step 3: Commit log.** diff --git a/docs/superpowers/plans/2026-04-25-peerinfo-enclosingpeer-engine-peer.md b/docs/superpowers/plans/2026-04-25-peerinfo-enclosingpeer-engine-peer.md deleted file mode 100644 index 4e3fdf4a09..0000000000 --- a/docs/superpowers/plans/2026-04-25-peerinfo-enclosingpeer-engine-peer.md +++ /dev/null @@ -1,516 +0,0 @@ -# Wave 50: enclosingPeer Peer? → EnginePeer? Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate the PeerInfo members chain's `enclosingPeer` field from raw Postbox `Peer?` to `EnginePeer?` (wave 50 of the Postbox → TelegramEngine refactor). - -**Architecture:** Cross-file private struct-field migration with stored-form ratchet. Edits stay inside `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`. Replaces `as? TelegramChannel` / `as? TelegramGroup` casts with `case let .channel(...)` / `case let .legacyGroup(...)` (wave-41/45 idiom), drops `is TelegramChannel` checks for `case .channel = ...` (wave-41 always-false-warning fix), and removes 5 internal `_asPeer()` / `EnginePeer(...)` / `flatMap(EnginePeer.init)` bridges. The engine.data subscription at PIMP:354 already returns `EnginePeer?` — this wave closes the demote-then-promote ratchet. - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build with `--continueOnError`. - -**Iteration budget:** 1–2 (target first-pass-clean; recent first-pass-clean streak: waves 42, 43*, 45, 46, 48, 49 — *wave 43 took 2 iterations). - -**Note on TDD:** This project has no unit tests (CLAUDE.md "No tests are used at the moment"). The standard TDD test-first cycle in the skill template does not apply. Each task instead writes the edits, then verifies via Bazel build + residue grep. - ---- - -## File Structure - -| File | Role | Changes | -|---|---|---| -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift` (PSMI) | List-item view-model + node | Type-change stored field + init param; 4 cast/is-check rewrites; 1 `flatMap(EnginePeer.init)` simplification | -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift` (PIMP) | Members-pane node + helpers | 3 func sigs + 1 stored field type-change; 4 cast/is-check rewrites; 1 `EnginePeer(...)` wrap drop; 2 `_asPeer()` drops | -| `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift` (PSPB) | Profile-items builder (non-settings members section) | 1 boundary `_asPeer()` drop at the call site that constructs the migrated init | - -No public-API ripple — `PeerInfoScreenMemberItem` and `PeerInfoMembersPaneNode` are local to the PeerInfoScreen module. - ---- - -## Task 1: PSMI.swift — type changes + cast/is-check rewrites + flatMap simplification - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift` - -**Edits in this task:** 7 (1 stored-field type, 1 init-param type, 2 cast→case-let, 2 is→case, 1 flatMap simplification). - -- [ ] **Step 1: Change stored field type at line 23** - -Find: - -```swift - let enclosingPeer: Peer? -``` - -Replace with: - -```swift - let enclosingPeer: EnginePeer? -``` - -- [ ] **Step 2: Change init parameter type at line 34** - -Find: - -```swift - enclosingPeer: Peer?, -``` - -Replace with: - -```swift - enclosingPeer: EnginePeer?, -``` - -- [ ] **Step 3: Rewrite cast at line 152 (TelegramChannel)** - -Find: - -```swift - if let channel = item.enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank) { -``` - -Replace with: - -```swift - if case let .channel(channel) = item.enclosingPeer, channel.hasPermission(.editRank) { -``` - -- [ ] **Step 4: Rewrite cast at line 154 (TelegramGroup)** - -Find: - -```swift - } else if let group = item.enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank) { -``` - -Replace with: - -```swift - } else if case let .legacyGroup(group) = item.enclosingPeer, !group.hasBannedPermission(.banEditRank) { -``` - -- [ ] **Step 5: Simplify flatMap at line 178** - -Find: - -```swift - let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer.flatMap(EnginePeer.init), member: item.member) -``` - -Replace with: - -```swift - let actions = availableActionsForMemberOfPeer(accountPeerId: item.context.accountPeerId, peer: item.enclosingPeer, member: item.member) -``` - -- [ ] **Step 6: Rewrite is-check at line 181** - -Find: - -```swift - if actions.contains(.promote) && item.enclosingPeer is TelegramChannel { -``` - -Replace with: - -```swift - if actions.contains(.promote), case .channel = item.enclosingPeer { -``` - -- [ ] **Step 7: Rewrite is-check at line 187** - -Find: - -```swift - if item.enclosingPeer is TelegramChannel { -``` - -Replace with: - -```swift - if case .channel = item.enclosingPeer { -``` - ---- - -## Task 2: PIMP.swift — signatures + stored field + body rewrites + demotion drops - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift` - -**Edits in this task:** 11 (3 func sigs + 1 stored-field type + 4 cast/is rewrites + 1 EnginePeer wrap drop + 2 `_asPeer()` drops). - -- [ ] **Step 1: Change `func item(...)` signature at line 92** - -Find: - -```swift - func item(context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem { -``` - -Replace with: - -```swift - func item(context: AccountContext, presentationData: PresentationData, enclosingPeer: EnginePeer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> ListViewItem { -``` - -- [ ] **Step 2: Rewrite cast at line 113 (TelegramChannel, non-optional context)** - -Find: - -```swift - if let channel = enclosingPeer as? TelegramChannel, channel.hasPermission(.editRank) { -``` - -Replace with: - -```swift - if case let .channel(channel) = enclosingPeer, channel.hasPermission(.editRank) { -``` - -- [ ] **Step 3: Rewrite cast at line 115 (TelegramGroup, non-optional context)** - -Find: - -```swift - } else if let group = enclosingPeer as? TelegramGroup, !group.hasBannedPermission(.banEditRank) { -``` - -Replace with: - -```swift - } else if case let .legacyGroup(group) = enclosingPeer, !group.hasBannedPermission(.banEditRank) { -``` - -- [ ] **Step 4: Drop the `EnginePeer(...)` wrap at line 139** - -Find: - -```swift - let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: EnginePeer(enclosingPeer), member: member) -``` - -Replace with: - -```swift - let actions = availableActionsForMemberOfPeer(accountPeerId: context.account.peerId, peer: enclosingPeer, member: member) -``` - -`availableActionsForMemberOfPeer` takes `peer: EnginePeer?` (PeerInfoData.swift:2314); Swift auto-wraps the non-optional `enclosingPeer: EnginePeer` to optional. - -- [ ] **Step 5: Rewrite is-check at line 142 (non-optional context)** - -Find: - -```swift - if actions.contains(.promote) && enclosingPeer is TelegramChannel { -``` - -Replace with: - -```swift - if actions.contains(.promote), case .channel = enclosingPeer { -``` - -- [ ] **Step 6: Rewrite is-check at line 148 (non-optional context)** - -Find: - -```swift - if enclosingPeer is TelegramChannel { -``` - -Replace with: - -```swift - if case .channel = enclosingPeer { -``` - -- [ ] **Step 7: Change `preparedTransition` signature at line 271** - -Find: - -```swift -private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toEntries: [PeerMembersListEntry], context: AccountContext, presentationData: PresentationData, enclosingPeer: Peer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> PeerMembersListTransaction { -``` - -Replace with: - -```swift -private func preparedTransition(from fromEntries: [PeerMembersListEntry], to toEntries: [PeerMembersListEntry], context: AccountContext, presentationData: PresentationData, enclosingPeer: EnginePeer, addMemberAction: @escaping () -> Void, action: @escaping (PeerInfoMember, PeerMembersListAction) -> Void, contextAction: ((PeerInfoMember, ASDisplayNode, ContextGesture?) -> Void)?) -> PeerMembersListTransaction { -``` - -- [ ] **Step 8: Change stored field type at line 293** - -Find: - -```swift - private var enclosingPeer: Peer? -``` - -Replace with: - -```swift - private var enclosingPeer: EnginePeer? -``` - -- [ ] **Step 9: Drop `_asPeer()` at line 361** - -Find: - -```swift - strongSelf.enclosingPeer = enclosingPeer._asPeer() -``` - -Replace with: - -```swift - strongSelf.enclosingPeer = enclosingPeer -``` - -- [ ] **Step 10: Drop `_asPeer()` at line 363** - -Find: - -```swift - strongSelf.updateState(enclosingPeer: enclosingPeer._asPeer(), state: state, presentationData: presentationData) -``` - -Replace with: - -```swift - strongSelf.updateState(enclosingPeer: enclosingPeer, state: state, presentationData: presentationData) -``` - -- [ ] **Step 11: Change `updateState` signature at line 442** - -Find: - -```swift - private func updateState(enclosingPeer: Peer, state: PeerInfoMembersState, presentationData: PresentationData) { -``` - -Replace with: - -```swift - private func updateState(enclosingPeer: EnginePeer, state: PeerInfoMembersState, presentationData: PresentationData) { -``` - -The pass-through call sites at PIMP:275, :276, :437, :438, :451, :485 require no edit — types flow through transparently. - ---- - -## Task 3: PSPB.swift — boundary lift at members-section call site - -**Files:** -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift` - -**Edits in this task:** 1. - -- [ ] **Step 1: Drop `_asPeer()` at line 852** - -Find: - -```swift - items[.peerMembers]!.append(PeerInfoScreenMemberItem(id: member.id, context: .account(context), enclosingPeer: peer._asPeer(), member: member, isAccount: false, action: isAccountPeer ? { _ in -``` - -Replace with: - -```swift - items[.peerMembers]!.append(PeerInfoScreenMemberItem(id: member.id, context: .account(context), enclosingPeer: peer, member: member, isAccount: false, action: isAccountPeer ? { _ in -``` - -`peer` here is the closure-bound `EnginePeer` from the `data.peer` source pipeline (`PeerInfoScreenData.peer: EnginePeer?` post-wave-42, unwrapped to non-optional `EnginePeer` and being passed to a now-`EnginePeer?` param — auto-promotes to optional). - -The other `PeerInfoScreenMemberItem(...)` construction at `PeerInfoSettingsItems.swift:132` passes `enclosingPeer: nil`, which is valid for either optional type — no edit. - ---- - -## Task 4: Full-project Bazel build - -**Files:** none (verification only). - -- [ ] **Step 1: Run the build with `--continueOnError`** - -Run: - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: clean build (`bazel build complete` or equivalent green output). - -- [ ] **Step 2: If build fails, triage iteration** - -If errors land in `PeerInfoScreenMemberItem.swift` or `PeerInfoMembersPane.swift` or `PeerInfoProfileItems.swift`: -- Read the failing line. -- Common failure modes from prior waves: - - **Always-false `is` warning under `-warnings-as-errors`**: leftover `is TelegramX` not converted in step. Re-grep `enclosingPeer is Telegram` over the 3 files. - - **Always-failing `as?` cast warning**: leftover `as? TelegramX` not converted. Re-grep `enclosingPeer.*as\?`. - - **Type mismatch on closure-capture alias**: a `strongSelf.enclosingPeer` or `self.enclosingPeer` site missed a `_asPeer()` drop. Re-grep `enclosingPeer\._asPeer\|EnginePeer\(enclosingPeer`. - - **Unused variable warning**: a binding from `case let .channel(channel)` not actually used. Re-read the body. - -Fix in place and re-run step 1. Budget: 2 iterations. - -If errors land outside those 3 files: **STOP**. The wave was supposed to be self-contained. Re-read the spec, identify the missed call site, decide whether to add it or abandon the wave. - ---- - -## Task 5: Post-edit residue grep - -**Files:** none (verification only). - -- [ ] **Step 1: Bridge residue grep** - -Run: - -```sh -grep -rnE "enclosingPeer\._asPeer|EnginePeer\(enclosingPeer\)|enclosingPeer\.flatMap\(EnginePeer" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ -``` - -Expected: empty output. - -- [ ] **Step 2: Cast/is-check residue grep** - -Run: - -```sh -grep -rnE "enclosingPeer.*as\? TelegramChannel|enclosingPeer.*as\? TelegramGroup|enclosingPeer is TelegramChannel" \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ -``` - -Expected: empty output. - -- [ ] **Step 3: Sanity check — `enclosingPeer` references should now exclusively type-resolve to EnginePeer** - -Run: - -```sh -grep -nE ": Peer\b" submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift -``` - -Expected: no `enclosingPeer: Peer` or `enclosingPeer: Peer?` annotations remain. (Other `: Peer` annotations on unrelated symbols are fine.) - ---- - -## Task 6: Commit the wave - -**Files:** none (git only). - -- [ ] **Step 1: Stage the 3 modified files** - -```sh -git add \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenMemberItem.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/Panes/PeerInfoMembersPane.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift -``` - -- [ ] **Step 2: Confirm staging is clean** - -```sh -git status --short | grep -v "^??" -``` - -Expected output: only the 3 staged files (lines starting with `M ` or `A `). If other modified files appear, they predate the wave (per CLAUDE.md memory: build-system/bazel-rules/sourcekit-bazel-bsp submodule marker is pre-existing WIP). - -- [ ] **Step 3: Commit** - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 50 - -Migrate enclosingPeer Peer? -> EnginePeer? across PeerInfoScreenMemberItem -+ PeerInfoMembersPaneNode + 1 PSPB call site. 19 edits / 3 files. - -Drops 5 internal bridges: 2 _asPeer() demotions at PIMP:361/363, 1 -EnginePeer(enclosingPeer) wrap at PIMP:139, 1 flatMap(EnginePeer.init) -at PSMI:178, 1 boundary _asPeer() lift at PSPB:852. - -Closes the wave-48-pattern internal-demotion-and-external-re-promotion -ratchet at PIMP:354-363 (engine.data subscription returns EnginePeer?, -previously demoted to Peer? at storage). - -All `as? TelegramChannel` / `as? TelegramGroup` casts converted to -`case let .channel(...)` / `case let .legacyGroup(...)` (wave-41/45 -idiom). All `is TelegramChannel` checks converted to -`case .channel = ...` (wave-41 always-false-warning fix). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit** - -```sh -git log --oneline -1 -``` - -Expected: shows the wave 50 commit. - ---- - -## Task 7: Update outcome log + memory - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` - -- [ ] **Step 1: Append wave 50 outcome to refactor log** - -Add a "Wave 50 outcome" entry at the appropriate chronological position in `docs/superpowers/postbox-refactor-log.md`. Use the wave 49 outcome entry as the template. Include: -- Commit hash (from Task 6 step 4). -- Iteration count (1 if first-pass-clean; 2 if Task 4 step 2 fired once). -- Net-bridge accounting: −5 internal bridges (2 `_asPeer()` + 1 `EnginePeer(...)` wrap + 1 `flatMap(EnginePeer.init)` + 1 boundary `_asPeer()` lift). 0 ADD wraps. 0 boundary lifts net new. -- Bazel build duration (from Task 4 step 1 output). -- Any wave-specific lessons surfaced. - -- [ ] **Step 2: Update wave-50-next-wave memory** - -Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Promote wave 50 outcome line into the "Latest commits" section using the format of the wave 49 entry. -- Update the top frontmatter `description` to reflect wave 50 landed and propose wave 51. -- Promote the wave-51 candidate (`PeerInfoGroupsInCommonPaneNode.PeerEntry.peer: Peer → EnginePeer`) to the top of the "Wave 51 candidates" section, replacing the now-stale "Wave 50 candidates" header. Re-run the broader grep if needed: - -```sh -grep -rnE "^\s*(let|var|public let|public var|private let|private var) [a-zA-Z_]+: Peer\??$|^\s*(let|var|public let|public var|private let|private var) [a-zA-Z_]+: Peer\? = " \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ --include="*.swift" | grep -v "EnginePeer" -``` - -- [ ] **Step 3: Commit the doc update** - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 50 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file updates are not committed — they live outside the repo.) - ---- - -## Net delta projection (from spec) - -| Category | Count | Sites | -|---|---|---| -| Internal bridge drops | −5 | PIMP:361, PIMP:363, PIMP:139, PSMI:178, PSPB:852 | -| Boundary lifts (net new) | 0 | source pipeline already EnginePeer? | -| ADD wraps | 0 | no Peer-only property accesses on bare `enclosingPeer` | -| Cast→case-let conversions | 4 | PSMI:152/154, PIMP:113/115 | -| `is`→`case` conversions | 4 | PSMI:181/187, PIMP:142/148 | -| Type annotations updated | 6 | PSMI:23/34, PIMP:92/271/293/442 | - -**Total commit footprint:** 19 line edits across 3 files, plus a docs commit for the outcome log. diff --git a/docs/superpowers/plans/2026-04-25-peerinfoscreendata-linked-peers-engine-peer.md b/docs/superpowers/plans/2026-04-25-peerinfoscreendata-linked-peers-engine-peer.md deleted file mode 100644 index 8abbf566ef..0000000000 --- a/docs/superpowers/plans/2026-04-25-peerinfoscreendata-linked-peers-engine-peer.md +++ /dev/null @@ -1,106 +0,0 @@ -# Wave 49 — `PeerInfoScreenData.linkedDiscussionPeer` + `.linkedMonoforumPeer` `Peer? → EnginePeer?` (bundle) - -**Date:** 2026-04-25 -**Predecessor:** Wave 48 (commit `1e4c2eea33`) — savedMessagesPeer single-field migration. -**Shape:** Cross-file bundled struct-field migration (2 sibling fields, 2 files). Both fields are module-internal; no external consumer references them on `PeerInfoScreenData`. Bundled because both fields: -- Share a sibling declaration site in `PeerInfoData.swift`. -- Have parallel local-source patterns (raw `Peer?` from `peerView.peers[id]` dict lookup; **not** an engine signal as in wave 48). -- Are both consumed in `PeerInfoProfileItems.swift` only. -- Migrating one without the other adds friction at the source-construction sites where they're computed together. - -## Pre-flight inventory - -`grep -rEn "(\w+\??)\.linkedDiscussionPeer\b|(\w+\??)\.linkedMonoforumPeer\b" submodules/ Telegram/`: -- `submodules/PeerInfoUI/Sources/ChannelDiscussionGroupSetupController.swift:600,651` — references are on a local `view` object (different type, NOT PeerInfoScreenData). Out of scope. -- All `data.linkedDiscussionPeer` / `data.linkedMonoforumPeer` accesses live in PIPI within the PeerInfoScreen module. - -Within scope: - -### `PeerInfoData.swift` (storage class + 2 init sites that compute the locals) - -| Site | Code | Action | -|------|------|--------| -| :396 | `let linkedDiscussionPeer: Peer?` (field decl) | Type → `EnginePeer?` | -| :397 | `let linkedMonoforumPeer: Peer?` (field decl) | Type → `EnginePeer?` | -| :453 | `linkedDiscussionPeer: Peer?,` (init param) | Type → `EnginePeer?` | -| :454 | `linkedMonoforumPeer: Peer?,` (init param) | Type → `EnginePeer?` | -| :498 | `self.linkedDiscussionPeer = linkedDiscussionPeer` | No change | -| :499 | `self.linkedMonoforumPeer = linkedMonoforumPeer` | No change | -| :1038, :1111, :1631 | `linkedDiscussionPeer: nil,` (init kwargs) | No change | -| :1039, :1112, :1632 | `linkedMonoforumPeer: nil,` (init kwargs) | No change | -| :1836 | `var discussionPeer: Peer?` (local) | Type → `EnginePeer?` | -| :1838 | `discussionPeer = peer` (where `peer = peerView.peers[linkedDiscussionPeerId]`, raw `Peer`) | Wrap → `discussionPeer = EnginePeer(peer)` | -| :1841 | `var monoforumPeer: Peer?` (local) | Type → `EnginePeer?` | -| :1843 | `monoforumPeer = peerView.peers[linkedMonoforumId]` (dict lookup, `Peer?`) | Wrap → `monoforumPeer = peerView.peers[linkedMonoforumId].flatMap(EnginePeer.init)` | -| :2131 | `var discussionPeer: Peer?` (local, parallel to :1836) | Type → `EnginePeer?` | -| :2133 | `discussionPeer = peer` (parallel to :1838) | Wrap → `discussionPeer = EnginePeer(peer)` | -| :2136 | `var monoforumPeer: Peer?` (local, parallel to :1841) | Type → `EnginePeer?` | -| :2138 | `monoforumPeer = peerView.peers[linkedMonoforumId]` (parallel to :1843) | Wrap with `.flatMap(EnginePeer.init)` | -| :1878, :1879, :2216, :2217 | init kwargs `linkedDiscussionPeer: discussionPeer,` / `linkedMonoforumPeer: monoforumPeer,` | No change (locals migrate; pass through) | - -That's **12 edits** in PID. Note the `var` declarations and assignments at :1836–:1843 and :2131–:2138 are *parallel pairs* (verified by grep). Use `replace_all=true` for the duplicate snippets. - -### `PeerInfoProfileItems.swift` (3 edits) - -| Site | Code | Action | -|------|------|--------| -| :1098 | `if let peer = data.linkedDiscussionPeer { ... }` | No change (binding works on `EnginePeer?`) | -| :1099 | `if let addressName = peer.addressName, !addressName.isEmpty {` | No change — `EnginePeer.addressName` forwarded (verified at `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:461`) | -| :1102 | `discussionGroupTitle = EnginePeer(peer).displayTitle(strings: ..., displayOrder: ...)` | **Drop wrap** → `peer.displayTitle(...)` | -| :1197 | `if let monoforumPeer = data.linkedMonoforumPeer as? TelegramChannel {` | **Pattern rewrite** → `if case let .channel(monoforumPeer) = data.linkedMonoforumPeer {` | -| :1198 | `monoforumPeer.sendPaidMessageStars` | No change — `sendPaidMessageStars` is a `TelegramChannel` property (`SyncCore_TelegramChannel.swift:215`); `case .channel` binds to `TelegramChannel` | -| :1404 | `if let linkedDiscussionPeer = data.linkedDiscussionPeer {` | No change (binding works) | -| :1406 | `if let addressName = linkedDiscussionPeer.addressName, !addressName.isEmpty {` | No change (forwarded) | -| :1409 | `peerTitle = EnginePeer(linkedDiscussionPeer).displayTitle(...)` | **Drop wrap** → `linkedDiscussionPeer.displayTitle(...)` | - -3 edits in PIPI. - -## EnginePeer property forwarding audit - -- `EnginePeer.addressName` — forwarded at `Peer.swift:461`. ✓ -- `EnginePeer.displayTitle(strings:displayOrder:)` — defined as `EnginePeer` instance method (used elsewhere via `EnginePeer(...).displayTitle(...)` pattern; once we have an `EnginePeer`, it's directly callable). ✓ -- `case .channel` binding payload is `TelegramChannel`. ✓ -- `TelegramChannel.sendPaidMessageStars` — exists (`SyncCore_TelegramChannel.swift:215`). ✓ - -## Net bridge count - -- **ADDs (4):** boundary lifts at PID:1838 (`EnginePeer(peer)`), PID:1843 (`.flatMap(EnginePeer.init)`), PID:2133, PID:2138. These lift the Postbox-typed `peerView.peers[...]` value to the engine type at the boundary — the correct semantic position for a Postbox→Engine refactor (mirrors wave 42 where `peer.flatMap(EnginePeer.init)` lift was added at PID:1620). -- **DROPs (2):** PIPI:1102 and :1409 lose `EnginePeer(...)` wraps around `displayTitle` calls. -- **Net text bridges:** +2. **But:** the ADDs are correct boundary lifts; the field-typed-as-`EnginePeer?` is the canonical state. The 2 displayTitle DROPs are the actual ratchet value. -- **Plus:** 1 cleaner pattern (PIPI:1197 `as?` cast → `case let .channel`), no text saving but better Swift idiom. - -## Edit list - -### `PeerInfoData.swift` (12 edits, but Edit text uses `replace_all=true` to bundle parallel pairs) - -1. Line 396: `let linkedDiscussionPeer: Peer?` → `let linkedDiscussionPeer: EnginePeer?` -2. Line 397: `let linkedMonoforumPeer: Peer?` → `let linkedMonoforumPeer: EnginePeer?` -3. Line 453: `linkedDiscussionPeer: Peer?,` → `linkedDiscussionPeer: EnginePeer?,` -4. Line 454: `linkedMonoforumPeer: Peer?,` → `linkedMonoforumPeer: EnginePeer?,` -5. Lines 1836 + 2131 (`replace_all=true` over `var discussionPeer: Peer?`): → `var discussionPeer: EnginePeer?` -6. Lines 1838 + 2133 (`replace_all=true` over `discussionPeer = peer`): → `discussionPeer = EnginePeer(peer)` -7. Lines 1841 + 2136 (`replace_all=true` over `var monoforumPeer: Peer?`): → `var monoforumPeer: EnginePeer?` -8. Lines 1843 + 2138 (`replace_all=true` over `monoforumPeer = peerView.peers[linkedMonoforumId]`): → `monoforumPeer = peerView.peers[linkedMonoforumId].flatMap(EnginePeer.init)` - -### `PeerInfoProfileItems.swift` (3 edits) - -9. Line 1102: `discussionGroupTitle = EnginePeer(peer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` → `discussionGroupTitle = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` -10. Line 1197: `if let monoforumPeer = data.linkedMonoforumPeer as? TelegramChannel {` → `if case let .channel(monoforumPeer) = data.linkedMonoforumPeer {` -11. Line 1409: `peerTitle = EnginePeer(linkedDiscussionPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` → `peerTitle = linkedDiscussionPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder)` - -## Out of scope - -- `PeerInfoScreenData.chatPeer` — large blast radius. Defer. -- `PeerInfoScreenMemberItem.enclosingPeer`. Defer. - -## Build & verify - -Standard Bazel command. Expected 1 iteration if forwarding audit holds; 2 if a `displayTitle` overload-resolution surprise surfaces. - -## Commit - -`Postbox -> TelegramEngine wave 49`. Body: bundle + edits summary + ADD/DROP accounting. - -## Outcome capture - -Append Wave 49 entry to `docs/superpowers/postbox-refactor-log.md`; update memory file. diff --git a/docs/superpowers/plans/2026-04-25-peerinfoscreendata-savedmessagespeer-engine-peer.md b/docs/superpowers/plans/2026-04-25-peerinfoscreendata-savedmessagespeer-engine-peer.md deleted file mode 100644 index fcbab698e1..0000000000 --- a/docs/superpowers/plans/2026-04-25-peerinfoscreendata-savedmessagespeer-engine-peer.md +++ /dev/null @@ -1,70 +0,0 @@ -# Wave 48 — `PeerInfoScreenData.savedMessagesPeer` `Peer? → EnginePeer?` - -**Date:** 2026-04-25 -**Predecessor:** Wave 47 (commit `d7b7536440`) — stored PHN.peer single-file private migration. -**Shape:** Cross-file struct-field migration. Storage class is internal to PeerInfoScreen module; no external consumer references PSD.savedMessagesPeer. - -## Target - -`submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoData.swift`, `PeerInfoScreenData.savedMessagesPeer: Peer?` at line 388. - -## Pre-flight inventory - -`grep -rEn "(\w+\??)\.savedMessagesPeer\b" submodules/ Telegram/` → matches only inside `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/`. No external consumer. The same field name appears in unrelated places (TelegramEngineMessages.swift, ChatListUI, etc.) but those are different declarations on different types. - -Within PeerInfoScreen module: - -| Site | Code | Action | -|------|------|--------| -| `PeerInfoData.swift:388` | `let savedMessagesPeer: Peer?` (struct field decl) | Type change → `EnginePeer?` | -| `PeerInfoData.swift:444` | `savedMessagesPeer: Peer?,` (init param) | Type change → `EnginePeer?` | -| `PeerInfoData.swift:489` | `self.savedMessagesPeer = savedMessagesPeer` (assignment) | No change (passthrough) | -| `PeerInfoData.swift:1029` | `savedMessagesPeer: nil,` (init kwarg) | No change (`nil` works for either) | -| `PeerInfoData.swift:1102` | `savedMessagesPeer: nil,` | No change | -| `PeerInfoData.swift:1313–1317` | `let savedMessagesPeer: Signal` (local) | No change — already `EnginePeer?` | -| `PeerInfoData.swift:1622` | `savedMessagesPeer: savedMessagesPeer?._asPeer(),` | **Drop bridge** → `savedMessagesPeer: savedMessagesPeer,` | -| `PeerInfoData.swift:1869` | `savedMessagesPeer: nil,` | No change | -| `PeerInfoData.swift:2207` | `savedMessagesPeer: nil,` | No change | -| `PeerInfoScreen.swift:5399` | `peer: self.data?.savedMessagesPeer.flatMap(EnginePeer.init) ?? self.data?.peer,` | **Drop bridge** → `peer: self.data?.savedMessagesPeer ?? self.data?.peer,` | -| `PeerInfoScreen.swift:5805` | same as :5399 | Same drop | - -Total edits: 5 (3 in PID, 2 in PIS). - -## EnginePeer / read-site audit - -The local signal at `PeerInfoData.swift:1313` already produces `EnginePeer?` from `engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(...))`. The `?._asPeer()` at line 1622 was an artificial demotion. Migrating the field type to `EnginePeer?` removes both the demotion at the storage site and the `flatMap(EnginePeer.init)` re-promotions at the read sites — a clean ratchet. - -PIS:5399 and :5805 use the field as input to `headerNode.update(... peer: ...)`, whose `peer` parameter has been `EnginePeer?` since wave 45. The `??` coalescing operand is `self.data?.peer` (already `EnginePeer?`). Result: drop the `.flatMap(EnginePeer.init)` and the expression compiles. - -## Edit list - -### PeerInfoData.swift (3 edits) - -1. Line 388: `let savedMessagesPeer: Peer?` → `let savedMessagesPeer: EnginePeer?` -2. Line 444: `savedMessagesPeer: Peer?,` → `savedMessagesPeer: EnginePeer?,` -3. Line 1622: `savedMessagesPeer: savedMessagesPeer?._asPeer(),` → `savedMessagesPeer: savedMessagesPeer,` - -### PeerInfoScreen.swift (2 edits, identical text) - -4. Line 5399: `peer: self.data?.savedMessagesPeer.flatMap(EnginePeer.init) ?? self.data?.peer,` → `peer: self.data?.savedMessagesPeer ?? self.data?.peer,` -5. Line 5805: same - -Use `replace_all=true` for the PIS edit since the matched text appears at both call sites verbatim. - -## Out of scope - -- `PeerInfoScreenData.chatPeer` — large blast radius (5 `as? TelegramX` checks downstream + ClearPeerHistory init parameter), defer. -- `PeerInfoScreenData.linkedDiscussionPeer`, `linkedMonoforumPeer` — both have `as? TelegramChannel` consumer sites in `PeerInfoProfileItems.swift`. Defer. -- `PeerInfoScreenMemberItem.enclosingPeer` — defer (separate target). - -## Build & verify - -Same Bazel command as wave 47. Expected 1-iteration first-pass-clean (single-pattern bridge removal, no enum-case rewrites, no Peer-only property access). - -## Commit - -`Postbox -> TelegramEngine wave 48`. Body lists the 5-edit summary and notes −3 internal bridges (1 PID + 2 PIS, identical PIS text appears twice). - -## Outcome capture - -Append a Wave 48 entry to `docs/superpowers/postbox-refactor-log.md` and update memory file `project_postbox_refactor_next_wave.md`. diff --git a/docs/superpowers/plans/2026-04-25-phn-peer-stored-field-engine-peer.md b/docs/superpowers/plans/2026-04-25-phn-peer-stored-field-engine-peer.md deleted file mode 100644 index 773f2bb01a..0000000000 --- a/docs/superpowers/plans/2026-04-25-phn-peer-stored-field-engine-peer.md +++ /dev/null @@ -1,62 +0,0 @@ -# Wave 47 — `PeerInfoHeaderNode.peer` stored field `Peer? → EnginePeer?` - -**Date:** 2026-04-25 -**Predecessor:** Wave 46 (commit `5ca99da5a7`) — PeerInfo avatar chain. -**Shape:** Single-file stored-field type migration. No external API change (field is `private`). - -## Target - -`submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoHeaderNode.swift`, stored field `private var peer: Peer?` at line 92. - -## Pre-flight inventory - -`grep -n "self\.peer\b" PeerInfoHeaderNode.swift` returns exactly 3 references: - -| Line | Code | Site type | Action | -|------|------|-----------|--------| -| 426 | `if let peer = self.peer, peer.profileImageRepresentations.isEmpty && gallery {` | Read | None — `profileImageRepresentations` is forwarded by `EnginePeer` (see `submodules/TelegramCore/Sources/TelegramEngine/Peers/Peer.swift:485`). Compiles unchanged. | -| 521 | `self.peer = peer?._asPeer()` | Assignment | Drop the bridge → `self.peer = peer`. The `peer` parameter is already `EnginePeer?` after wave 45. | -| 2049–2054 | `guard let self, let peer = self.peer, ...` followed by `peer: EnginePeer(peer),` | Read | Drop the wrap at line 2054 → `peer: peer,`. | - -External access check: `grep -rn "headerNode\.peer\b" submodules/ Telegram/` returns empty. The field is private; only same-file siblings touch it. - -EnginePeer forwarding (re-confirmed at plan time): -- `profileImageRepresentations` — forwarded (Peer.swift:485). ✓ -- `EnginePeer(peer)` (PHN:2054) — accepts `EnginePeer` directly when the local is already `EnginePeer`; drop the constructor. - -Field-declaration change is the only "type" change needed. The 3 callers' adjustments are mechanical bridge drops. - -## Edit list - -1. Line 92: `private var peer: Peer?` → `private var peer: EnginePeer?` -2. Line 521: `self.peer = peer?._asPeer()` → `self.peer = peer` -3. Line 2054: `peer: EnginePeer(peer),` → `peer: peer,` - -Total: 3 edits in 1 file. - -## Out of scope - -- `PeerInfoData.swift:355,487` — different classes' `self.peer` assignments (different types). Audit confirms these are `RenderedChannelParticipant.peer` and similar — already migrated in earlier waves or owned by other types. -- `PeerInfoAvatarTransformContainerNode.peer` (line 223) — already `EnginePeer?` after wave 46. - -## Build & verify - -```sh -source ~/.zshrc 2>/dev/null; \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: 1-iteration first-pass-clean. Only PeerInfoScreen + TelegramUI recompile. - -## Commit - -`Postbox -> TelegramEngine wave 47`. Body lists the 3-edit summary and notes -3 internal bridges. - -## Outcome capture - -Append a Wave 47 entry to `docs/superpowers/postbox-refactor-log.md` and update memory file `project_postbox_refactor_next_wave.md`. diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node.md b/docs/superpowers/plans/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node.md deleted file mode 100644 index 68d5d0311b..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-103-chat-recent-actions-controller-node.md +++ /dev/null @@ -1,347 +0,0 @@ -# Wave 103: ChatRecentActionsControllerNode peer Peer → EnginePeer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate `ChatRecentActionsControllerNode`'s stored `peer: Peer` field to `EnginePeer`, dropping the `_asPeer()` boundary call at the single caller site (wave 103 of the Postbox → TelegramEngine refactor). - -**Architecture:** Wave-71-shadow close. Single-file private stored-form migration plus a 1-line caller drop. The caller (`ChatRecentActionsController`) already holds `peer: EnginePeer` and demotes once before passing into the node init. The wave drops the demotion and rewrites 3 `as? TelegramChannel` downcasts inside the node body to `case let .channel(...)` (wave-41/45 idiom). All scope is within `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/`. - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 1 (target first-pass-clean given the 7-edit scope and validated pre-flight grep). - -**Note on TDD:** This project has no unit tests. The standard TDD test-first cycle does not apply. Each task writes the edits, then verifies via Bazel build + residue grep. - ---- - -## File Structure - -| File | Role | Changes | -|---|---|---| -| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` (CRACN) | Recent-actions screen controller node | Drop `import Postbox`, retype stored field + init param, rewrite 3 `as? TelegramChannel` downcasts (6 edits) | -| `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` (CRAC) | Recent-actions screen controller (caller) | Drop `_asPeer()` at the node init (1 edit) | - -No public-API ripple — `ChatRecentActionsControllerNode` is local to the module and has a single caller verified by grep. - ---- - -## Task 1: CRACN.swift — drop `import Postbox` + type changes + downcast rewrites - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` - -**Edits in this task:** 6 (1 import drop, 1 stored-field retype, 1 init-param retype, 3 cast → case-let). - -- [ ] **Step 1: Drop `import Postbox` at line 5** - -Find: - -```swift -import Postbox -``` - -Replace with: (delete the line entirely) - -This file imports `TelegramCore` at line 4, which provides the `EnginePeer` type and the typealiases needed for the rest of this task. - -- [ ] **Step 2: Retype stored field at line 46** - -Find: - -```swift - private let peer: Peer -``` - -Replace with: - -```swift - private let peer: EnginePeer -``` - -- [ ] **Step 3: Retype init parameter at line 111** - -Find: - -```swift - init(context: AccountContext, controller: ChatRecentActionsController, peer: Peer, presentationData: PresentationData, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, PresentationContextType, Any?) -> Void, getNavigationController: @escaping () -> NavigationController?) { -``` - -Replace with: - -```swift - init(context: AccountContext, controller: ChatRecentActionsController, peer: EnginePeer, presentationData: PresentationData, pushController: @escaping (ViewController) -> Void, presentController: @escaping (ViewController, PresentationContextType, Any?) -> Void, getNavigationController: @escaping () -> NavigationController?) { -``` - -- [ ] **Step 4: Rewrite downcast at line 899** - -Find: - -```swift - if let peer = strongSelf.peer as? TelegramChannel { -``` - -Replace with: - -```swift - if case let .channel(peer) = strongSelf.peer { -``` - -The bound name `peer` is preserved so the inner block (`switch peer.info { case .group: ... }`) ports verbatim. `case let .channel(peer)` binds `peer: TelegramChannel` directly (the associated value of `EnginePeer.channel`). - -- [ ] **Step 5: Rewrite downcast at line 948** - -Find: - -```swift - if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { -``` - -Replace with: - -```swift - if case let .channel(channel) = self.peer, case .broadcast = channel.info { -``` - -The compound condition (`, case .broadcast = channel.info`) ports verbatim because the bound `channel` is still `TelegramChannel`-typed. - -- [ ] **Step 6: Rewrite downcast at line 1088** - -Find: - -```swift - if let channel = self.peer as? TelegramChannel { -``` - -Replace with: - -```swift - if case let .channel(channel) = self.peer { -``` - -The inner block (`channel.hasPermission(.banMembers)`, `case .broadcast = channel.info`) ports verbatim. - -The `self.peer.id` accesses at lines 145, 161, 1138, 1490 require no edit — `EnginePeer.id` is a typealiased `PeerId`, identical at the call sites. - ---- - -## Task 2: CRAC.swift — drop boundary `_asPeer()` - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift` - -**Edits in this task:** 1. - -- [ ] **Step 1: Drop `_asPeer()` at line 277** - -Find: - -```swift - self.displayNode = ChatRecentActionsControllerNode(context: self.context, controller: self, peer: self.peer._asPeer(), presentationData: self.presentationData, pushController: { [weak self] c in -``` - -Replace with: - -```swift - self.displayNode = ChatRecentActionsControllerNode(context: self.context, controller: self, peer: self.peer, presentationData: self.presentationData, pushController: { [weak self] c in -``` - -`ChatRecentActionsController.peer` is already declared `EnginePeer` at line 42 (`public init(context: AccountContext, peer: EnginePeer, ...)`) — the type carries through to the now-`EnginePeer`-typed init parameter. - ---- - -## Task 3: Full-project Bazel build - -**Files:** none (verification only). - -- [ ] **Step 1: Run the build** - -Run: - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: clean build (`bazel build complete` or equivalent green output). No `--continueOnError` because the small scope makes the first error informative. - -Build cost projection: consumer-only, ~25s. If it exceeds ~60s, suspect a cascade leak. - -- [ ] **Step 2: If build fails, triage iteration** - -If errors land in `ChatRecentActionsControllerNode.swift` or `ChatRecentActionsController.swift`: -- Read the failing line. -- Common failure modes from prior waves: - - **Always-false `is` warning under `-warnings-as-errors`:** none expected here (pre-flight grep confirmed no `is TelegramChannel` checks on `self.peer`). If one surfaces anyway, convert to `case .channel = self.peer`. - - **Always-failing `as?` cast warning:** leftover `as? TelegramX` not converted in step 4/5/6. Re-grep `(self|strongSelf)\.peer as\?` over the file. - - **Type mismatch on closure-capture alias:** none expected here (pre-flight grep confirmed only `strongSelf.peer` and `self.peer` aliases, both ride the type change). - - **Type mismatch on `.id` access:** would indicate a regression in the `EnginePeer.Id` typealias — STOP and re-read CLAUDE.md, this is not a wave-103 issue. - - **Unused-variable warning under `-warnings-as-errors`:** a `case let .channel(peer)` binding not used inside the body. Re-read step 4/5/6 — if the inner block never references the bound name, switch to `case .channel = ...` and remove the binding. - -Fix in place and re-run step 1. Budget: 2 iterations. - -If errors land outside those 2 files: **STOP**. The wave was supposed to be self-contained. Re-read the spec, identify the missed call site, decide whether to add it or abandon the wave. - ---- - -## Task 4: Post-edit residue grep - -**Files:** none (verification only). - -- [ ] **Step 1: Cast residue grep** - -Run: - -```sh -grep -nE "(self|strongSelf)\.peer as\? Telegram(Channel|Group|User)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ -``` - -Expected: empty output. - -- [ ] **Step 2: Boundary `_asPeer()` residue grep** - -Run: - -```sh -grep -nE "self\.peer\._asPeer\(\)" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ -``` - -Expected: empty output. - -- [ ] **Step 3: `import Postbox` residue grep** - -Run: - -```sh -grep -rn "^import Postbox$" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ -``` - -Expected: empty output. The module is now Postbox-import-free. - -- [ ] **Step 4: Sanity check — `peer: Peer` annotations** - -Run: - -```sh -grep -nE "peer: Peer\b" \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift -``` - -Expected: empty output. (The 3 `as? TelegramChannel` downcasts on `self.peer` were the only sources; both `peer: Peer` annotations on stored field and init param are now `peer: EnginePeer`.) - ---- - -## Task 5: Commit the wave - -**Files:** none (git only). - -- [ ] **Step 1: Stage the 2 modified files** - -```sh -git add \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift -``` - -- [ ] **Step 2: Confirm staging is clean** - -```sh -git status --short | grep -v "^??" -``` - -Expected output: only the 2 staged files (lines starting with `M `). If other modified files appear, they predate the wave (per CLAUDE.md memory: `build-system/bazel-rules/sourcekit-bazel-bsp` submodule marker is pre-existing WIP). - -- [ ] **Step 3: Commit** - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 103 - -Migrate ChatRecentActionsControllerNode.peer Peer -> EnginePeer. -Closes the wave-71 shadow: caller already held EnginePeer and demoted -at the boundary. 7 edits / 2 files. - -Drops 1 boundary _asPeer() at ChatRecentActionsController:277, drops -import Postbox at ChatRecentActionsControllerNode:5, rewrites 3 -`as? TelegramChannel` downcasts to `case let .channel(...)` (wave-41/45 -idiom). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit** - -```sh -git log --oneline -1 -``` - -Expected: shows the wave 103 commit as HEAD. - ---- - -## Task 6: Update outcome log + memory - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md` - -- [ ] **Step 1: Append wave 103 outcome to refactor log** - -Append a "Wave 103 outcome" entry at the chronological end of `docs/superpowers/postbox-refactor-log.md`. Use the most recent wave-outcome entry as a structural template. Include: -- Commit hash (from Task 5 step 4). -- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired). -- Net-bridge accounting: −1 boundary `_asPeer()` (CRAC:277), −1 `import Postbox` (CRACN:5). 0 ADD wraps. 3 cast → case-let conversions (CRACN:899/948/1088). -- Bazel build duration (from Task 3 step 1 output). -- Wave-shape note: wave-71-shadow close, single-iter target validated. - -- [ ] **Step 2: Update next-wave memory** - -Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`: -- Add the wave 103 outcome line into the recent-waves section (commit hash + 7-edit / 2-file / 1-iter summary). -- Remove the now-stale `ChatRecentActionsControllerNode.peer: Peer -> EnginePeer` candidate line (currently bullet 5 in the candidates list). -- Update the top frontmatter `description` to reflect wave 103 landed and propose wave 104. -- Promote the next candidate (likely one of: `cachedResourceRepresentation` foundational facade, `RenderedPeer` cascade kickoff, `SelectivePrivacyPeer` foundational, or another Shape-C/D mini-refactor) to the top of the candidates list. - -- [ ] **Step 3: Update MEMORY.md index** - -Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`: -- Update the `[Postbox refactor next wave]` line to mention wave 103 landed and shift the "Wave 103+ Shape-C/D candidates" framing forward to "Wave 104+ candidates". - -- [ ] **Step 4: Commit the doc update** - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 103 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file updates at `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/` are not committed — they live outside the repo.) - ---- - -## Net delta projection (from spec) - -| Category | Count | Sites | -|---|---|---| -| Internal bridge drops | −1 | CRAC:277 (`_asPeer()`) | -| `import Postbox` drops | −1 | CRACN:5 | -| ADD wraps | 0 | no Peer-only property accesses on bare `self.peer` | -| Cast → case-let conversions | 3 | CRACN:899, CRACN:948, CRACN:1088 | -| Type annotations updated | 2 | CRACN:46 (stored field), CRACN:111 (init param) | -| Postbox-free module count | +1 | `Components/Chat/ChatRecentActionsController/` joins the list | - -**Total commit footprint:** 7 line edits across 2 files, plus a docs commit for the outcome log. diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md b/docs/superpowers/plans/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md deleted file mode 100644 index efc202c33d..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-103-retry-account-manager-store-resource-data-drain.md +++ /dev/null @@ -1,260 +0,0 @@ -# Wave 103 (retry): accountManager.mediaBox.storeResourceData drain Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drain 5 remaining `accountManager.mediaBox.storeResourceData(...)` Shape-A sites against the wave-94 `AccountManagerResources.storeResourceData(id:data:synchronous:)` facade. Wave 103 (retry) of the Postbox → TelegramEngine refactor, after the abandonment of the original wave-103 plan. - -**Architecture:** Wave-shape-G drain. Pure call-site rewrite; no facade addition, no TelegramCore touch, no public-API change. 5 sites across 2 consumer files (`ThemeUpdateManager.swift`, `WallpaperResources.swift`) migrated via 3 `Edit` calls (1 single + 2 `replace_all=true` batches). - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests (per `CLAUDE.md`). Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 1 (target first-pass-clean given mechanical scope and validated facade). - -**Note on TDD:** This project has no unit tests. Each task writes the edits, then verifies via Bazel build + residue grep. - ---- - -## File Structure - -| File | Role | Changes | -|---|---|---| -| `submodules/TelegramUI/Sources/ThemeUpdateManager.swift` | Theme-update background sync | 1 site migrated | -| `submodules/WallpaperResources/Sources/WallpaperResources.swift` | Wallpaper resource pipeline | 4 sites migrated via 2 `replace_all=true` batches | - -No public-API ripple — both files are leaf consumers of the wave-94 facade. - ---- - -## Task 1: ThemeUpdateManager.swift — single-site migration - -**Files:** -- Modify: `submodules/TelegramUI/Sources/ThemeUpdateManager.swift` - -**Edits in this task:** 1. - -- [ ] **Step 1: Migrate the storeResourceData call at line 112** - -Find: - -```swift - accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData, synchronous: true) -``` - -Replace with: - -```swift - accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData, synchronous: true) -``` - -`accountManager` here is closure-captured from `presentationThemeSettingsUpdated(_:)` scope, typed `AccountManager`. The facade is exposed via `public extension AccountManager { var resources: AccountManagerResources }`. - ---- - -## Task 2: WallpaperResources.swift — two batched migrations - -**Files:** -- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift` - -**Edits in this task:** 2 (each `replace_all=true`, covering 2 sites apiece). - -- [ ] **Step 1: Migrate the `reference.resource.id` pattern (lines 973, 1214)** - -Use `Edit` with `replace_all=true`: - -Find: - -```swift -accountManager.mediaBox.storeResourceData(reference.resource.id, data: data) -``` - -Replace with: - -```swift -accountManager.resources.storeResourceData(id: EngineMediaResource.Id(reference.resource.id), data: data) -``` - -Both sites share identical text (verified by pre-flight grep). `replace_all=true` handles both atomically. - -- [ ] **Step 2: Migrate the `file.file.resource.id` pattern (lines 1260, 1523)** - -Use `Edit` with `replace_all=true`: - -Find: - -```swift -accountManager.mediaBox.storeResourceData(file.file.resource.id, data: fullSizeData) -``` - -Replace with: - -```swift -accountManager.resources.storeResourceData(id: EngineMediaResource.Id(file.file.resource.id), data: fullSizeData) -``` - -Both sites share identical text. `replace_all=true` handles both atomically. - ---- - -## Task 3: Full-project Bazel build - -**Files:** none (verification only). - -- [ ] **Step 1: Run the build** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: clean build (`bazel build complete` / `INFO: Build completed successfully`). No `--continueOnError` because the small scope makes the first error informative. - -Build cost projection: WallpaperResources is foundational with wide rebuild fan-out; expect ~30-90s. - -- [ ] **Step 2: If build fails, triage iteration** - -Common failure modes: -- **`EngineMediaResource.Id` not in scope** — verify `import TelegramCore` is at the top of the failing file (it should be — pre-flight inventoried both files have it). If absent, add it. -- **Type mismatch on `id:` parameter** — would suggest an unexpected `MediaResourceId` subtype. STOP and re-read; the migration assumed `MediaResource.id: MediaResourceId` for both `reference.resource` and `file.file.resource`. Both should resolve to `MediaResourceId` per Postbox protocol. -- **`accountManager.resources` not in scope** — the `public extension AccountManager` exists in TelegramCore (wave 94). If unreachable, the consumer's BUILD might be missing a TelegramCore dep — but both files already use TelegramCore types, so this should not happen. STOP if it does. - -If errors land outside those 2 files: **STOP and report BLOCKED**. The wave is supposed to be self-contained. - -Fix in place and re-run step 1. Budget: 2 iterations. - ---- - -## Task 4: Post-edit residue grep - -**Files:** none (verification only). - -- [ ] **Step 1: Verify zero remaining `accountManager.mediaBox.storeResourceData` in the 2 touched files** - -Run: - -```sh -grep -rn "accountManager\.mediaBox\.storeResourceData" \ - submodules/TelegramUI/Sources/ThemeUpdateManager.swift \ - submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: empty output. - ---- - -## Task 5: Commit the wave - -**Files:** none (git only). - -- [ ] **Step 1: Stage the 2 modified files** - -```sh -git add \ - submodules/TelegramUI/Sources/ThemeUpdateManager.swift \ - submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -- [ ] **Step 2: Confirm staging is clean** - -```sh -git status --short | grep -v "^??" -``` - -Expected output: only the 2 staged files (lines starting with `M `). The line `m build-system/bazel-rules/sourcekit-bazel-bsp` is pre-existing WIP and should NOT appear in the staged list. - -- [ ] **Step 3: Commit** - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 103 (retry) - -Drain 5 accountManager.mediaBox.storeResourceData(...) Shape-A sites -that the wave-94/95-99 sweep missed. All 5 migrated to -accountManager.resources.storeResourceData(id: EngineMediaResource.Id(...)) -against the existing wave-94 facade. - -Sites: ThemeUpdateManager:112 (with synchronous: true), -WallpaperResources:973, 1214 (reference.resource.id pattern, replace_all), -WallpaperResources:1260, 1523 (file.file.resource.id pattern, replace_all). - -5 sites / 2 files / 3 Edit calls. Consumer-only build. - -Wave-103 retry after the abandonment of ChatRecentActionsControllerNode -peer migration; see postbox-refactor-log "Wave 103 outcome" for the -forensics. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit** - -```sh -git log --oneline -1 -``` - -Expected: shows the wave 103 (retry) commit as HEAD. - ---- - -## Task 6: Update outcome log + memory - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md` - -- [ ] **Step 1: Append wave 103 (retry) outcome to refactor log** - -Append a "Wave 103 (retry) outcome" entry to `docs/superpowers/postbox-refactor-log.md`. Include: -- Commit hash (from Task 5 step 4). -- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired). -- Bazel build duration. -- Net-delta accounting: −5 raw `mediaBox.X` accesses, +5 facade calls, +5 `EngineMediaResource.Id(...)` wraps (canonical engine-side, not Postbox bridges). -- Wave-shape note: G drain, validates the wave-94 facade across an additional 2-module footprint. - -- [ ] **Step 2: Update next-wave memory** - -Edit `project_postbox_refactor_next_wave.md`: -- Add wave 103 (retry) outcome line into the recent-waves section. -- Mark the 5 sites as drained; remove from candidate inventories (the file currently lists "Wave 95+ candidates" with stale storeResourceData entries — clean those up). -- Update the top frontmatter `description` to reflect wave 103 (retry) landed. -- Promote next candidate. Options: 7-site `resourceData(...)` drain (would need a new facade method or use existing `data(resource:)`), DirectMediaImageCache Shape-C/D, or pivot to a foundational wave. - -- [ ] **Step 3: Update MEMORY.md index** - -Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`: -- Update the `[Postbox refactor next wave]` line to mention wave 103 (retry) landed. - -- [ ] **Step 4: Commit the doc update** - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 103 (retry) outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file updates are not committed — they live outside the repo.) - ---- - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| Raw `mediaBox.X` access drops | −5 | TUM:112 + WR:973, 1214, 1260, 1523 | -| Facade calls added | +5 | same sites, migrated form | -| `EngineMediaResource.Id(...)` wraps | +5 | canonical engine-side constructs (not Postbox bridges) | -| `import Postbox` drops | 0 | both files retain Postbox import for unrelated symbols | -| Postbox-free module count | 0 | no module dropped from the import list | - -**Total commit footprint:** 5 line edits (3 Edit calls) across 2 files, plus a docs commit for the outcome log. diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md b/docs/superpowers/plans/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md deleted file mode 100644 index de643a1f87..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-104-account-manager-resource-data-drain-3-sites.md +++ /dev/null @@ -1,331 +0,0 @@ -# Wave 104: accountManager.mediaBox.resourceData drain (3 clean sites) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drain 3 of 8 `accountManager.mediaBox.resourceData(...)` Shape-A sites against the existing wave-32 / wave-94 `AccountManagerResources.data(resource:)` facade. Wave 104 of the Postbox → TelegramEngine refactor. - -**Architecture:** Wave-shape-G drain with a documented consumer field rename. Single-file consumer migration in `submodules/WallpaperResources/Sources/WallpaperResources.swift`. 3 call rewrites + 3 consumer-side `.complete` → `.isComplete` renames, 6 Edit calls total. The remaining 5 of the original 8 `resourceData` candidates are deferred (2 cross a `MediaResourceData` flow-out cascade, 3 are coupled to postbox-side via `combineLatest` typed tuples). - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 1 (target first-pass-clean given verified pre-flight inventory). - -**Note on TDD:** This project has no unit tests. Each task writes the edits, then verifies via Bazel build + residue grep. - ---- - -## File Structure - -| File | Role | Changes | -|---|---|---| -| `submodules/WallpaperResources/Sources/WallpaperResources.swift` | Wallpaper resource pipeline | 3 call rewrites + 3 consumer renames | - -No public-API ripple — leaf-consumer migration against an existing facade. - ---- - -## Task 1: WallpaperResources.swift — call rewrites (3 edits) - -**Files:** -- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift` - -**Edits in this task:** 3. - -- [ ] **Step 1: Migrate the call at line 957 (`reference.resource` argument)** - -Find: - -```swift - let maybeFetched = accountManager.mediaBox.resourceData(reference.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad) -``` - -Replace with: - -```swift - let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(reference.resource), attemptSynchronously: synchronousLoad) -``` - -Note: `waitUntilFetchStatus: false` is omitted because the facade default is `false`. The site explicitly passed `false`, so behavior is preserved. - -- [ ] **Step 2: Migrate the call at line 1164 (`fileReference.media.resource` argument)** - -Find: - -```swift - let maybeFetched = accountManager.mediaBox.resourceData(fileReference.media.resource, option: .complete(waitUntilFetchStatus: false), attemptSynchronously: synchronousLoad) -``` - -Replace with: - -```swift - let maybeFetched = accountManager.resources.data(resource: EngineMediaResource(fileReference.media.resource), attemptSynchronously: synchronousLoad) -``` - -Same `waitUntilFetchStatus: false` omission rationale. - -- [ ] **Step 3: Migrate the call at line 1264 (`file.file.resource` argument, no option)** - -Find: - -```swift - return accountManager.mediaBox.resourceData(file.file.resource) -``` - -Replace with: - -```swift - return accountManager.resources.data(resource: EngineMediaResource(file.file.resource)) -``` - -The original used the underlying `MediaBox.resourceData(_ resource:)` overload's defaults — facade defaults match exactly (`pathExtension: nil`, `waitUntilFetchStatus: false`, `attemptSynchronously: false`). - ---- - -## Task 2: WallpaperResources.swift — consumer-side `.complete` → `.isComplete` renames (3 edits) - -**Files:** -- Modify: `submodules/WallpaperResources/Sources/WallpaperResources.swift` - -**Edits in this task:** 3. - -`EngineMediaResource.ResourceData` exposes `.isComplete` (renamed from `MediaResourceData.complete`). All three migrated call sites have a single consumer-side `.complete` access on the migrated result that needs renaming. - -- [ ] **Step 1: Rename `maybeData.complete` at line 961 (consumer of site 957)** - -Find: - -```swift - if maybeData.complete { -``` - -Replace with: - -```swift - if maybeData.isComplete { -``` - -The leading whitespace (8 spaces) must match exactly. - -- [ ] **Step 2: Rename `maybeData.complete` at line 1168 (consumer of site 1164)** - -Find: - -```swift - if maybeData.complete && isSupportedTheme { -``` - -Replace with: - -```swift - if maybeData.isComplete && isSupportedTheme { -``` - -The leading whitespace (16 spaces) must match exactly. - -- [ ] **Step 3: Rename `data.complete` at line 1266 (consumer of site 1264)** - -Find: - -```swift - if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { -``` - -Replace with: - -```swift - if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { -``` - -The leading whitespace (36 spaces) must match exactly. - -The `data.path` access on the same line is unchanged — both `MediaResourceData.path` and `EngineMediaResource.ResourceData.path` are `String`. - ---- - -## Sites NOT touched (deferred) - -For the implementer's awareness — these `.complete` accesses on UNRELATED bindings stay raw and are NOT to be renamed: - -- `WallpaperResources.swift:968` — `return data.complete ? try? Data(contentsOf: URL(fileURLWithPath: data.path)) : nil` — this `data` is bound from `account.postbox.mediaBox.resourceData(...)` (postbox-side, not migrated). STAYS `.complete`. -- Other `.complete` accesses elsewhere in the file that aren't on the 3 migrated bindings — STAY. - -The 3 renames target only the 3 specific lines listed in Task 2 steps 1-3. Do NOT use `replace_all=true` for renames — bindings differ per scope. - ---- - -## Task 3: Full-project Bazel build - -**Files:** none (verification only). - -- [ ] **Step 1: Run the build** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Expected: clean build (`bazel build complete` / `INFO: Build completed successfully`). No `--continueOnError`. Build cost projection: ~30-60s (consumer-only, foundational module rebuild fan-out). - -- [ ] **Step 2: If build fails, triage iteration** - -Common failure modes: -- **`EngineMediaResource` constructor not found** — verify `import TelegramCore` at the top of WallpaperResources.swift (it should already be there). If missing, add it. -- **Type mismatch on `resource:` parameter** — would suggest the argument expression isn't `MediaResource`-typed. STOP and check the actual type at the failing site. -- **Type mismatch on `.isComplete` rename** — if the closure parameter binding is somehow inferred wrong (e.g., Swift inferred the OLD `MediaResourceData` type because the call rewrite didn't take effect), the rename will fail. Re-read the diff and verify the call rewrite landed. -- **`data.path` type mismatch** — should not happen; both types expose `path: String`. If it does, STOP and re-read. - -If errors land outside WallpaperResources.swift: STOP and report BLOCKED. The wave is supposed to be self-contained. - -Iteration budget: 2. - ---- - -## Task 4: Post-edit residue grep - -**Files:** none (verification only). - -- [ ] **Step 1: Verify the 3 migrated call sites are gone** - -Run: - -```sh -grep -nE "accountManager\.mediaBox\.resourceData\(" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: exactly 3 lines remaining (L33, L59, L401 — the deferred combineLatest sites). The migrated lines (originally 957, 1164, 1264) should NOT appear. - -- [ ] **Step 2: Verify the 3 renames are applied** - -Run: - -```sh -grep -nE "maybeData\.complete\b" submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: empty output. Both `maybeData.complete` accesses (originally L961, L1168) should be gone. - -```sh -grep -nE "if data\.complete," submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -Expected: no line at L1266 (the migrated site). Other `data.complete` accesses on postbox-side bindings (e.g., L968) may remain — those are out of scope. - ---- - -## Task 5: Commit the wave - -**Files:** none (git only). - -- [ ] **Step 1: Stage the 1 modified file** - -```sh -git add submodules/WallpaperResources/Sources/WallpaperResources.swift -``` - -- [ ] **Step 2: Confirm staging is clean** - -```sh -git status --short | grep -v "^??" -``` - -Expected: only the 1 staged file (line starting with `M `). The line `m build-system/bazel-rules/sourcekit-bazel-bsp` is pre-existing WIP and should NOT appear in the staged list. - -- [ ] **Step 3: Commit** - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 104 - -Drain 3 accountManager.mediaBox.resourceData(...) Shape-A sites against -the existing wave-32 / wave-94 AccountManagerResources.data(resource:) -facade. Sites: WallpaperResources:957 (reference.resource), :1164 -(fileReference.media.resource), :1264 (file.file.resource). - -Migration: accountManager.mediaBox.resourceData(X, option: .complete( -waitUntilFetchStatus: false)[, attemptSynchronously: Y]) -> accountManager -.resources.data(resource: EngineMediaResource(X)[, attemptSynchronously: -Y]). Plus 3 consumer-side .complete -> .isComplete renames at L961, -L1168, L1266 to match EngineMediaResource.ResourceData field name. - -3 sites / 1 file / 6 Edit calls. Consumer-only build. - -Deferred: 2 sites in FetchCachedRepresentations.swift (482, 490) flow -data: MediaResourceData into fetchCachedScaled*Representation cascade; -3 sites in WallpaperResources (33, 59, 401) coupled to postbox-side via -combineLatest typed tuples. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit** - -```sh -git log --oneline -1 -``` - -Expected: shows the wave 104 commit as HEAD. - ---- - -## Task 6: Update outcome log + memory - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md` - -- [ ] **Step 1: Append wave 104 outcome to refactor log** - -Append a "Wave 104 outcome" entry to `docs/superpowers/postbox-refactor-log.md` matching the format of "Wave 103 (retry) outcome". Include: -- Commit hash (from Task 5 step 4). -- Iteration count (1 if first-pass-clean; 2 if Task 3 step 2 fired). -- Bazel build duration (from Task 3 step 1 output). -- Net-delta accounting: −3 raw `mediaBox.X` accesses, +3 facade calls, +3 `EngineMediaResource(...)` wraps, +3 consumer field renames. -- Wave-shape note: G drain with documented consumer field rename. The pre-flight identified a `MediaResourceData`-typed-function-parameter barrier (`fetchCachedScaled*Representation` family) that forced 2 sites into the deferred bucket — illustrates the wave-71-shadow lesson applied to result-type cascades, not just peer migrations. - -- [ ] **Step 2: Update next-wave memory** - -Edit `project_postbox_refactor_next_wave.md`: -- Add wave 104 outcome line into the recent-waves section. -- Update accountManager-side facade drain status table: `resourceData` count drops from 8 → 5 (3 drained, 5 deferred). -- Add a new section (or extend an existing one) documenting the "Postbox-typed-function-parameter barrier" pattern, with `Message.peers: SimpleDictionary` (wave-103 lesson) and now `fetchCachedScaled*Representation(resourceData: MediaResourceData)` as the two known instances. - -- [ ] **Step 3: Update MEMORY.md index** - -Edit `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md`: -- Update the `[Postbox refactor next wave]` line to mention wave 104 landed. - -- [ ] **Step 4: Commit the doc update** - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 104 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file updates are not committed — they live outside the repo.) - ---- - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| Raw `mediaBox.X` access drops | −3 | WR:957, 1164, 1264 | -| Facade calls added | +3 | same sites, migrated form | -| `EngineMediaResource(...)` wraps | +3 | canonical engine-side, not Postbox bridges | -| Consumer field renames | +3 | WR:961 (`maybeData.complete` → `.isComplete`), WR:1168 (same), WR:1266 (`data.complete` → `.isComplete`) | -| `import Postbox` drops | 0 | WallpaperResources retains import for unrelated symbols | - -**Total commit footprint:** 6 line edits in 1 file, plus a docs commit for the outcome log. diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md b/docs/superpowers/plans/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md deleted file mode 100644 index a191faea48..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-105-device-contact-info-subject-engine-peer.md +++ /dev/null @@ -1,489 +0,0 @@ -# Wave 105: DeviceContactInfoSubject enum payload Peer? → EnginePeer? Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Migrate `DeviceContactInfoSubject` enum's 3 case payloads + 2 callback signatures + 1 computed property from raw Postbox `Peer?` to `EnginePeer?`. Wave 105 of the Postbox → TelegramEngine refactor. - -**Architecture:** Multi-module enum-payload migration (wave-91 shape). 17 edits across 5 files. AccountContext.swift hosts the enum + property. DeviceContactInfoController.swift is the primary consumer. 4 construction sites in TelegramUI/PeerInfoUI/StoryContainerScreen/ChatController. Net wrap delta: −8 (drops 10, adds 2 at Chat-side construction barriers documented per spec). - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 1-3 (wave-91 precedent: 2 iter for similar shape). - -**Note on TDD:** No unit tests in this project. Each task writes the edits, then verifies via Bazel build + residue grep. - ---- - -## File Structure - -| File | Role | Edits | -|---|---|---| -| `submodules/AccountContext/Sources/AccountContext.swift` | Enum definition + computed property | 4 type-line edits | -| `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` | Primary consumer | 9 edits (5 `_asPeer` drops + 3 `.flatMap` simplifications + 1 downcast rewrite) | -| `submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift` | Chat-side construction (Pattern E ADD bridges) | 1 Edit (replace_all=true covers 2 sites) | -| `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` | Story-side construction | 1 edit | -| `submodules/TelegramUI/Sources/OpenChatMessage.swift` | OpenChatMessage construction | 1 edit | - ---- - -## Task 1: AccountContext.swift — enum + computed property type changes - -**File:** `submodules/AccountContext/Sources/AccountContext.swift` - -- [ ] **Step 1: Migrate the 3 enum case payloads (single Edit covers consecutive lines)** - -Find: - -```swift -public enum DeviceContactInfoSubject { - case vcard(Peer?, DeviceContactStableId?, DeviceContactExtendedData) - case filter(peer: Peer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (Peer?, DeviceContactExtendedData) -> Void) - case create(peer: Peer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (Peer?, DeviceContactStableId, DeviceContactExtendedData) -> Void) - - public var peer: Peer? { -``` - -Replace with: - -```swift -public enum DeviceContactInfoSubject { - case vcard(EnginePeer?, DeviceContactStableId?, DeviceContactExtendedData) - case filter(peer: EnginePeer?, contactId: DeviceContactStableId?, contactData: DeviceContactExtendedData, completion: (EnginePeer?, DeviceContactExtendedData) -> Void) - case create(peer: EnginePeer?, contactData: DeviceContactExtendedData, isSharing: Bool, shareViaException: Bool, completion: (EnginePeer?, DeviceContactStableId, DeviceContactExtendedData) -> Void) - - public var peer: EnginePeer? { -``` - -This single Edit covers all 4 type-line changes in `AccountContext.swift`. The `contactData: DeviceContactExtendedData` computed property (lines 719-727) is unaffected. - ---- - -## Task 2: DeviceContactInfoController.swift — Pattern D downcast rewrite (1 edit) - -**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` - -- [ ] **Step 1: Rewrite the `as? TelegramUser` downcast at line 849** - -Find: - -```swift - if let peer = peer as? TelegramUser { -``` - -Replace with: - -```swift - if case let .user(peer) = peer { -``` - -The leading whitespace (8 spaces) must match exactly. The outer `peer: EnginePeer?` (from `case let .create(peer, ...) = subject` at L845) is shadowed inside the if-body by `peer: TelegramUser` (the `.user` case associated value). Inner body access (`peer.firstName`, `peer.lastName`, `peer.phone`) works on the rebinding. - ---- - -## Task 3: DeviceContactInfoController.swift — Pattern C `.flatMap` simplifications (3 edits) - -**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` - -- [ ] **Step 1: Simplify `.vcard` case body at line 942** - -Find: - -```swift - case let .vcard(peer, id, data): - contactData = .single((peer.flatMap(EnginePeer.init), id, data)) -``` - -Replace with: - -```swift - case let .vcard(peer, id, data): - contactData = .single((peer, id, data)) -``` - -- [ ] **Step 2: Simplify `.filter` case body at line 944** - -Find: - -```swift - case let .filter(peer, id, data, _): - contactData = .single((peer.flatMap(EnginePeer.init), id, data)) -``` - -Replace with: - -```swift - case let .filter(peer, id, data, _): - contactData = .single((peer, id, data)) -``` - -- [ ] **Step 3: Simplify `.create` case body at line 946** - -Find: - -```swift - case let .create(peer, data, share, shareViaExceptionValue, _): - contactData = .single((peer.flatMap(EnginePeer.init), nil, data)) -``` - -Replace with: - -```swift - case let .create(peer, data, share, shareViaExceptionValue, _): - contactData = .single((peer, nil, data)) -``` - -After Task 1's enum migration, the destructured `peer: EnginePeer?` is the target type — `.flatMap(EnginePeer.init)` becomes a redundant round-trip. - ---- - -## Task 4: DeviceContactInfoController.swift — Pattern B `_asPeer` drops at completion calls (2 edits) - -**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` - -- [ ] **Step 1: Drop `_asPeer()` at completion call line 1105** - -Find: - -```swift - completion(peerAndContactData.0?._asPeer(), filteredData) -``` - -Replace with: - -```swift - completion(peerAndContactData.0, filteredData) -``` - -`peerAndContactData.0` is `EnginePeer?` from the typed signal at L939. Completion's first parameter type changes from `Peer?` to `EnginePeer?` per Task 1. - -- [ ] **Step 2: Drop `_asPeer()` at completion call line 1224** - -Find: - -```swift - completion(contactIdAndData.2?._asPeer(), contactIdAndData.0, contactIdAndData.1) -``` - -Replace with: - -```swift - completion(contactIdAndData.2, contactIdAndData.0, contactIdAndData.1) -``` - -`contactIdAndData.2` is `EnginePeer?` per the typed signal `(DeviceContactStableId, DeviceContactExtendedData, EnginePeer?)?` declared at L1175. - ---- - -## Task 5: DeviceContactInfoController.swift — Pattern A `_asPeer` drops at construction (3 edits) - -**File:** `submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift` - -- [ ] **Step 1: Drop `_asPeer()` at line 1289** - -Find: - -```swift - replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer?._asPeer(), contactId, contactData), completed: nil, cancelled: nil)) -``` - -Replace with: - -```swift - replaceControllerImpl?(deviceContactInfoController(context: context, environment: environment, subject: .vcard(peer, contactId, contactData), completed: nil, cancelled: nil)) -``` - -- [ ] **Step 2: Drop `_asPeer()` at line 1443** - -Find: - -```swift - parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in -``` - -Replace with: - -```swift - parentController.present(deviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: false, shareViaException: false, completion: { peer, stableId, contactData in -``` - -- [ ] **Step 3: Drop `_asPeer()` at line 1489** - -Find: - -```swift - controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer?._asPeer(), contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in -``` - -Replace with: - -```swift - controller?.present(context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: context), environment: ShareControllerAppEnvironment(sharedContext: context.sharedContext), subject: .create(peer: peer, contactData: contactData, isSharing: peer != nil, shareViaException: false, completion: { _, _, _ in -``` - -All 3 sites have `peer` source already typed as `EnginePeer?` per inventory. - ---- - -## Task 6: ChatControllerOpenAttachmentMenu.swift — Pattern E ADD wraps (1 Edit, 2 sites via replace_all=true) - -**File:** `submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift` - -- [ ] **Step 1: Add `.flatMap(EnginePeer.init)` wrap at lines 683 and 1850** - -Use Edit with `replace_all=true`. Find: - -```swift -subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { peer, contactData in -``` - -Replace with: - -```swift -subject: .filter(peer: peerAndContactData.0.flatMap(EnginePeer.init), contactId: nil, contactData: contactData, completion: { peer, contactData in -``` - -`replace_all=true` is required — both sites at L683 and L1850 share identical text. The upstream signal type is `(Peer?, DeviceContactExtendedData?)` (verified at L634 and L1822); `.flatMap(EnginePeer.init)` wraps `Peer?` to `EnginePeer?` to satisfy the migrated `.filter(peer: EnginePeer?, ...)` signature. - ---- - -## Task 7: StoryItemSetContainerViewSendMessage.swift — Pattern A `_asPeer` drop (1 edit) - -**File:** `submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift` - -- [ ] **Step 1: Drop `_asPeer()` at line 2132** - -Find: - -```swift - let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0?._asPeer(), contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in -``` - -Replace with: - -```swift - let contactController = component.context.sharedContext.makeDeviceContactInfoController(context: ShareControllerAppAccountContext(context: component.context), environment: ShareControllerAppEnvironment(sharedContext: component.context.sharedContext), subject: .filter(peer: peerAndContactData.0, contactId: nil, contactData: contactData, completion: { [weak self, weak view] peer, contactData in -``` - -`peerAndContactData.0` is `EnginePeer?` from the typed signal at this site (the presence of `?._asPeer()` confirms it). - ---- - -## Task 8: OpenChatMessage.swift — Pattern A `_asPeer` drop (1 edit) - -**File:** `submodules/TelegramUI/Sources/OpenChatMessage.swift` - -- [ ] **Step 1: Drop `_asPeer()` at line 443** - -Find: - -```swift - let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer?._asPeer(), nil, contactData), completed: nil, cancelled: nil) -``` - -Replace with: - -```swift - let controller = deviceContactInfoController(context: ShareControllerAppAccountContext(context: params.context), environment: ShareControllerAppEnvironment(sharedContext: params.context.sharedContext), updatedPresentationData: params.updatedPresentationData, subject: .vcard(peer, nil, contactData), completed: nil, cancelled: nil) -``` - -`peer` source is already `EnginePeer?` (the `?._asPeer()` confirms the source type). - ---- - -## Task 9: Full-project Bazel build - -**Files:** none (verification only). - -- [ ] **Step 1: Run the build with `--continueOnError`** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -`--continueOnError` enabled — multi-module wave; surface all errors at once if iter-1 fails. - -Expected: clean build. AccountContext is foundational; expect 60-180s build cost. - -- [ ] **Step 2: If build fails, triage iteration** - -Common failure modes (per wave-91 precedent): -- **Type mismatch on a destructured `peer`** — a destructure body may use `peer.X` where `X` is a Peer-protocol-only method not on EnginePeer. Pre-flight inventory found ZERO such sites, but verify the failing line. -- **`.id` access on EnginePeer? doesn't compile** — would indicate an EnginePeer.Id typealias regression (very unlikely; would have failed all prior waves). -- **`case let .user(peer) = peer` doesn't compile** — verify the outer `peer` is `EnginePeer?` (after migration) and not still `Peer?`. -- **A construction site missed an `_asPeer()` drop** — re-grep `_asPeer\(\)` over the 5 touched files. -- **Hidden `Peer?`-typed completion call site** — would indicate an unmigrated callback consumer. Re-grep across consumer module sources. - -If errors land outside the 5 touched files: STOP and report BLOCKED — the wave is supposed to be self-contained. - -Iteration budget: 3. - ---- - -## Task 10: Post-edit residue grep - -**Files:** none (verification only). - -- [ ] **Step 1: Construction-site `_asPeer` residue (expected empty)** - -```sh -grep -nE "subject:\s*\.(vcard|filter|create)\(.*_asPeer\(\)" \ - submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift \ - submodules/TelegramUI/Sources/OpenChatMessage.swift -``` - -- [ ] **Step 2: Completion `_asPeer` residue (expected empty)** - -```sh -grep -nE "completion\(.*_asPeer\(\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -``` - -- [ ] **Step 3: `.flatMap(EnginePeer.init)` simplification residue (expected empty)** - -```sh -grep -nE "peer\.flatMap\(EnginePeer\.init\)" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -``` - -- [ ] **Step 4: Downcast residue (expected empty)** - -```sh -grep -nE "peer as\? TelegramUser" submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift -``` - -- [ ] **Step 5: ADD wraps applied (expected 2 lines)** - -```sh -grep -nE "peerAndContactData\.0\.flatMap\(EnginePeer\.init\)" submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift -``` - -Expected: 2 lines (originally L683 and L1850, line numbers may have shifted slightly). - ---- - -## Task 11: Commit the wave - -**Files:** none (git only). - -- [ ] **Step 1: Stage the 5 modified files** - -```sh -git add \ - submodules/AccountContext/Sources/AccountContext.swift \ - submodules/PeerInfoUI/Sources/DeviceContactInfoController.swift \ - submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift \ - submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift \ - submodules/TelegramUI/Sources/OpenChatMessage.swift -``` - -- [ ] **Step 2: Confirm staging** - -```sh -git status --short | grep -v "^??" -``` - -Expected: 5 staged files (lines starting with `M `). The pre-existing `m build-system/bazel-rules/sourcekit-bazel-bsp` WIP marker should NOT appear in staged. - -- [ ] **Step 3: Commit** - -```sh -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 105 - -Migrate DeviceContactInfoSubject enum 3 case Peer? payloads + 2 callback -(Peer?, ...) -> Void signatures + 1 computed peer: Peer? property to -EnginePeer?. Wave-91-pattern multi-module enum-payload migration. - -Drops 10 wraps: -- 5 _asPeer() at construction sites: DeviceContactInfoController:1289, - 1443, 1489 + StoryItemSetContainerViewSendMessage:2132 + - OpenChatMessage:443. -- 2 _asPeer() at completion-call sites: - DeviceContactInfoController:1105, 1224. -- 3 .flatMap(EnginePeer.init) simplifications at - DeviceContactInfoController:942, 944, 946. - -Adds 2 ADD bridges: ChatControllerOpenAttachmentMenu:683, 1850 — both -construct .filter(peer:) from peerAndContactData.0 typed (Peer?, ...); -.flatMap(EnginePeer.init) wraps to EnginePeer?. Net wrap delta: -8. - -Plus 1 downcast rewrite: DeviceContactInfoController:849 — `if let peer -= peer as? TelegramUser` to `if case let .user(peer) = peer`. - -5 files / 17 edits. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Verify commit** - -```sh -git log --oneline -1 -``` - ---- - -## Task 12: Update outcome log + memory - -**Files:** -- Modify: `docs/superpowers/postbox-refactor-log.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` -- Modify: `~/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/MEMORY.md` - -- [ ] **Step 1: Append wave 105 outcome to refactor log** - -Include: -- Commit hash (from Task 11 step 4). -- Iteration count (1 if first-pass-clean; 2-3 if Task 9 step 2 fired). -- Bazel build duration. -- Net-delta accounting: −10 wrap drops, +2 ADD wraps, +1 downcast rewrite. Net −8 wraps. -- Wave-shape note: wave-91-pattern multi-module enum-payload migration with full pre-flight inventory clearing layers 1-4 of the wave-71-shadow checklist. Documents the value of thorough pre-flight inventory: 17 mechanical edits with 0 surprises. - -- [ ] **Step 2: Update next-wave memory** - -Edit `project_postbox_refactor_next_wave.md`: -- Add wave 105 outcome line into the recent-waves section. -- Mark `DeviceContactInfoSubject` candidate as drained (currently bullet 9 in deferred list). -- Promote next candidate. - -- [ ] **Step 3: Update MEMORY.md index** - -Update the `[Postbox refactor next wave]` line. - -- [ ] **Step 4: Commit the doc update** - -```sh -git add docs/superpowers/postbox-refactor-log.md -git commit -m "$(cat <<'EOF' -docs: log wave 105 outcome - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -(Memory file updates not committed — they live outside the repo.) - ---- - -## Net delta projection - -| Category | Count | Sites | -|---|---|---| -| `_asPeer()` drops at construction | −5 | DCIC:1289, 1443, 1489 + SISCVSM:2132 + OCM:443 | -| `_asPeer()` drops at completion calls | −2 | DCIC:1105, 1224 | -| `.flatMap(EnginePeer.init)` simplifications | −3 | DCIC:942, 944, 946 | -| `.flatMap(EnginePeer.init)` ADD wraps | +2 | CCOAM:683, 1850 | -| Downcast → case-let | +1 | DCIC:849 | -| Type annotations migrated | 4 | AccountContext: 3 enum cases + 1 computed property | - -**Total commit footprint:** 17 line edits across 5 files, plus a docs commit for the outcome log. - -**Net wrap delta:** **−8** (the wave's headline metric). diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-106-import-drop-sweep.md b/docs/superpowers/plans/2026-04-26-postbox-wave-106-import-drop-sweep.md deleted file mode 100644 index b36195daf1..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-106-import-drop-sweep.md +++ /dev/null @@ -1,493 +0,0 @@ -# Wave 106: Speculative `import Postbox` Drop Sweep (round 2) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drop `import Postbox` from any consumer-module Swift file in `submodules/` whose remaining content no longer references a Postbox-only symbol. Wave 106 of the Postbox → TelegramEngine refactor — round 2 of the wave-93 speculative-drop sweep. - -**Architecture:** Procedural sweep with build-feedback loop. (1) inventory candidates → (2) pre-flight regex pre-restore → (3) drop imports en masse → (4) build with `--continueOnError` → (5) restore failures → iterate → (6) final clean build → (7) optional BUILD-dep sweep → (8) single atomic commit. No code semantic changes — only `import` and BUILD `deps` lines. - -**Tech Stack:** Swift, Bazel via `Make.py`, `grep`/`sed` for inventory, no unit tests. Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 2-5 build cycles (wave-93 precedent: 2 iter — drop, restore, clean). - -**Note on TDD:** No unit tests in this project. Each task verifies via Bazel build + diff inspection. Build feedback IS the test. - -**Spec:** `docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md`. - ---- - -## File Structure - -| Artifact | Role | -|---|---| -| `/tmp/wave106-candidates.txt` | All consumer files currently `import Postbox` | -| `/tmp/wave106-skiplist.txt` | Files that match preemptive-restore regex (keep import) | -| `/tmp/wave106-droplist.txt` | candidates − skiplist; files to edit | -| `/tmp/wave106-build-iterN.log` | Per-iteration build log | -| `/tmp/wave106-restore-iterN.txt` | Files needing restore after iter N | -| `/tmp/wave106-final-droplist.txt` | Net dropped files after all iterations | -| `submodules/**/*.swift` | Edited files (single-line Edit each) | -| `submodules/**/BUILD` | (Optional Step 7) packages with no remaining Postbox imports | - ---- - -## Task 1: Pre-flight WIP check - -**File:** none (read-only). - -- [ ] **Step 1: Verify clean working tree (modulo known-persistent state)** - -Run: - -```sh -git status --short -``` - -Expected output: - -``` - m build-system/bazel-rules/sourcekit-bazel-bsp -?? build-system/tulsi/ -?? submodules/TgVoip/ -?? third-party/libx264/ -``` - -If output contains anything else (modified `M` files, other untracked dirs), HALT — there is unrelated WIP that would get tangled with the wave commit. Resolve before proceeding. - -- [ ] **Step 2: Confirm we are on `master`** - -Run: - -```sh -git branch --show-current -``` - -Expected: `master`. If not, stop and ask. - ---- - -## Task 2: Inventory candidate files - -**File:** none (read-only). - -- [ ] **Step 1: Build the candidate list** - -Run: - -```sh -grep -rl "^import Postbox" submodules --include="*.swift" \ - | grep -v "^submodules/Postbox/" \ - | grep -v "^submodules/TelegramCore/" \ - | grep -v "^submodules/TelegramApi/" \ - | sort -u > /tmp/wave106-candidates.txt -wc -l /tmp/wave106-candidates.txt -``` - -Expected: between ~700 and ~1200 files (wave-93-era was ~1200; waves 94-105 may have peeled some). - -- [ ] **Step 2: Sanity-check the exclusion filters worked** - -Run: - -```sh -grep -E "^submodules/(Postbox|TelegramCore|TelegramApi)/" /tmp/wave106-candidates.txt | head -5 -``` - -Expected: empty output (no excluded paths leaked through). - ---- - -## Task 3: Build the skip-list via preemptive regex - -**File:** none (read-only). - -- [ ] **Step 1: Run the combined skip-regex against candidates** - -The skip-regex is the union of three tiers from the spec. Run: - -```sh -grep -El "\bPostbox\b|\bMediaBox\b|\bMediaResource\b|\bMediaResourceData\b|\bMediaResourceId\b|\bPostboxCoding\b|\bPostboxDecoder\b|\bPostboxEncoder\b|\bMemoryBuffer\b|\bTempBoxFile\b|\bValueBoxKey\b|\bPostboxView\b|\bcombinedView\b|\bPeerId\b|\bMessageId\b|\bMediaId\b|\bMessageIndex\b|\bMessageAndThreadId\b|\bPeerNameIndex\b|\bStoryId\b|\bItemCollectionId\b|\bFetchResourceSourceType\b|\bFetchResourceError\b|\bPeer\b|\bMessage\b|\bMedia\b" \ - $(cat /tmp/wave106-candidates.txt) \ - | sort -u > /tmp/wave106-skiplist.txt -wc -l /tmp/wave106-skiplist.txt -``` - -Expected: most of the candidate list (likely 600-1100 files matched) — `\bPeer\b`, `\bMessage\b`, `\bMedia\b` are deliberately broad and catch many false positives. False positives are SAFE — they just mean fewer drops, not bad drops. - -- [ ] **Step 2: Compute the drop-list** - -Run: - -```sh -comm -23 /tmp/wave106-candidates.txt /tmp/wave106-skiplist.txt > /tmp/wave106-droplist.txt -wc -l /tmp/wave106-droplist.txt -head -20 /tmp/wave106-droplist.txt -``` - -Expected: 5-50 files in the drop-list (wave 93 had 12). If 0, the regex is over-matching — halt and revisit. If >100, the regex is under-matching — halt, expand patterns, re-run. - -- [ ] **Step 3: Spot-verify 3 random drop candidates** - -Run for each of 3 files from the head of the drop-list: - -```sh -head -3 /tmp/wave106-droplist.txt | while read f; do - echo "=== $f ===" - grep -nE "Postbox|MediaBox|MediaResource|PeerId|MessageId|MediaId|MessageIndex" "$f" | head -5 -done -``` - -Expected: Only `import Postbox` line appears. If any other Postbox-token appears, the file should have been skipped — add the missing pattern to the regex in Step 1, redo Steps 1-2, and re-spot-check. - ---- - -## Task 4: Drop `import Postbox` from drop-list files - -**Files:** every path listed in `/tmp/wave106-droplist.txt`. - -- [ ] **Step 1: Read each drop-list file's import block to locate the exact `import Postbox` line** - -For each file in the drop-list, the line is `import Postbox` (exact match, no whitespace variations expected). Use a single-purpose `sed` to remove it from all drop-list files: - -```sh -while read f; do - sed -i '' '/^import Postbox$/d' "$f" -done < /tmp/wave106-droplist.txt -``` - -The `sed -i ''` syntax is BSD/macOS specific — required on Darwin. - -- [ ] **Step 2: Verify the imports were removed** - -Run: - -```sh -grep -lE "^import Postbox$" $(cat /tmp/wave106-droplist.txt) | wc -l -``` - -Expected: 0 (no file in the drop-list still contains `import Postbox`). - -- [ ] **Step 3: Verify no other lines were touched** - -Run: - -```sh -git diff --stat | tail -5 -git diff --shortstat -``` - -Expected: same number of files modified as drop-list size. Each file should show `-1` insertion (or `-1` deletion). If any file shows multiple deletions, something went wrong — `git checkout -- $(cat /tmp/wave106-droplist.txt)` and investigate. - ---- - -## Task 5: Build iteration 1 — capture failures - -**File:** none (build only). - -- [ ] **Step 1: Run the build with `--continueOnError`** - -Run: - -```sh -source ~/.zshrc 2>/dev/null && \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 \ - --continueOnError 2>&1 | tee /tmp/wave106-build-iter1.log -``` - -Expected: Build completes (with errors). Wall-clock 30-260s depending on cache state. - -- [ ] **Step 2: Extract failing files** - -Run: - -```sh -grep -E ":[0-9]+:[0-9]+: error:" /tmp/wave106-build-iter1.log \ - | awk -F: '{print $1}' \ - | sort -u > /tmp/wave106-restore-iter1.txt -wc -l /tmp/wave106-restore-iter1.txt -cat /tmp/wave106-restore-iter1.txt -``` - -Expected: a subset of the drop-list. Wave 93 saw 5 of 12 needing restore. If the count > 50% of drop-list, the regex is missing a major pattern — HALT, analyze the failure cluster, add the missing pattern to Task 3 Step 1, restart from Task 4. - -- [ ] **Step 3: Verify no errors in TelegramCore/Postbox/TelegramApi** - -Run: - -```sh -grep -E "^submodules/(TelegramCore|Postbox|TelegramApi)/" /tmp/wave106-restore-iter1.txt -``` - -Expected: empty. If non-empty: HALT immediately, `git checkout -- submodules/`, and revert the wave — scope drift indicates the candidate filter or sed pattern is wrong. - ---- - -## Task 6: Restore failing files (iter 1) - -**Files:** every path in `/tmp/wave106-restore-iter1.txt`. - -- [ ] **Step 1: Re-add `import Postbox` to each failing file** - -Use awk uniformly (BSD `sed -i '' 'i\'` line-continuation is fragile inside shell loops). Insert `import Postbox` immediately before `import TelegramCore` if present, else immediately after the first existing `import ` line: - -```sh -while read f; do - awk ' - BEGIN { added = 0 } - !added && /^import TelegramCore$/ { print "import Postbox"; print; added = 1; next } - { print } - END { - if (!added) { - # fallback path was not used — try post-first-import injection - # (this END block is a no-op; awk cannot re-emit lines after END) - } - } - ' "$f" > "$f.tmp" - if ! grep -q "^import Postbox$" "$f.tmp"; then - # no TelegramCore anchor found — fall back to "after first import" - awk ' - BEGIN { added = 0 } - !added && /^import / { print; print "import Postbox"; added = 1; next } - { print } - ' "$f" > "$f.tmp" - fi - mv "$f.tmp" "$f" -done < /tmp/wave106-restore-iter1.txt -``` - -- [ ] **Step 2: Verify restorations** - -Run: - -```sh -grep -L "^import Postbox$" $(cat /tmp/wave106-restore-iter1.txt) -``` - -Expected: empty (every file in the restore list now contains `import Postbox` again). - -- [ ] **Step 3: Update the working drop-list** - -Run: - -```sh -comm -23 /tmp/wave106-droplist.txt /tmp/wave106-restore-iter1.txt > /tmp/wave106-final-droplist.txt -wc -l /tmp/wave106-final-droplist.txt -``` - -This is the current "successfully dropped" set. - ---- - -## Task 7: Build iteration 2 — verify clean (or iterate further) - -**File:** none (build only). - -- [ ] **Step 1: Re-run the build with `--continueOnError`** - -Run: - -```sh -source ~/.zshrc 2>/dev/null && \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 \ - --continueOnError 2>&1 | tee /tmp/wave106-build-iter2.log -``` - -- [ ] **Step 2: Extract any new failures** - -Run: - -```sh -grep -E ":[0-9]+:[0-9]+: error:" /tmp/wave106-build-iter2.log \ - | awk -F: '{print $1}' \ - | sort -u > /tmp/wave106-restore-iter2.txt -wc -l /tmp/wave106-restore-iter2.txt -``` - -- [ ] **Step 3: If non-empty, repeat Task 6 with `iter2.txt` and run Task 7 again as iter3.** - -Stop when: -- `restore-iterN.txt` is empty → proceed to Task 8. -- `N == 5` → HALT (diminishing returns); commit what is green via Task 9. - -Each repeat: substitute `iter1` → `iter2` → `iter3` etc. throughout. Update the final-droplist after each restore: `comm -23 /tmp/wave106-final-droplist.txt /tmp/wave106-restore-iterN.txt > /tmp/wave106-final-droplist.txt.new && mv /tmp/wave106-final-droplist.txt.new /tmp/wave106-final-droplist.txt`. - ---- - -## Task 8: Final clean build (no `--continueOnError`) - -**File:** none (build only). - -- [ ] **Step 1: Run a clean build to confirm no inter-module ordering issue was masked** - -Run: - -```sh -source ~/.zshrc 2>/dev/null && \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 2>&1 | tail -30 -``` - -Expected: build success, no `error:` lines in the tail. If failure: an inter-module visibility issue exists that `--continueOnError` masked. Restore the final-droplist file(s) implicated by the error, repeat Task 7 / Task 8. - ---- - -## Task 9 (optional): BUILD-dep sweep - -**Files:** various `submodules/*/BUILD` files. - -This step removes `//submodules/Postbox` from any Bazel package whose Swift sources no longer contain `import Postbox`. Skip this task if iteration time is constrained — the import drops alone are the core wave value; deps trim is housekeeping. - -- [ ] **Step 1: Find packages whose `BUILD` still depends on `//submodules/Postbox` but whose `Sources/**/*.swift` no longer imports it** - -Run: - -```sh -for build in $(find submodules -name BUILD -not -path "submodules/Postbox/*"); do - pkg_dir=$(dirname "$build") - if grep -q "//submodules/Postbox" "$build" 2>/dev/null; then - if ! grep -rq "^import Postbox$" "$pkg_dir" --include="*.swift" 2>/dev/null; then - echo "$build" - fi - fi -done > /tmp/wave106-build-deps-candidates.txt -wc -l /tmp/wave106-build-deps-candidates.txt -cat /tmp/wave106-build-deps-candidates.txt -``` - -Expected: 0-5 candidates. If 0, skip to Task 10. - -- [ ] **Step 2: For each candidate BUILD, locate the `//submodules/Postbox` line and remove it via Edit** - -Note that BUILD files often list deps as either `"//submodules/Postbox"` (string) or via aliases. Use Read to inspect each, then Edit to drop just the dep line. The exact string pattern varies — typically `"//submodules/Postbox",` on its own line within a `deps = [ ... ]` block. - -For each candidate file, Read the lines around the match, then Edit to remove the line preserving the surrounding bracket structure. - -- [ ] **Step 3: Re-run the clean build (no `--continueOnError`) to confirm no dep was load-bearing** - -Run the same command as Task 8 Step 1. - -If failure: a transitive dep was being satisfied through Postbox. Restore the dep line(s) implicated by the error and re-run. - ---- - -## Task 10: Commit the wave - -**File:** `git`. - -- [ ] **Step 1: Inspect final diff statistics** - -Run: - -```sh -git diff --stat -git diff --shortstat -``` - -Expected: N file modifications, all `-1` line changes (just `import Postbox` lines), possibly plus a small number of BUILD diffs from Task 9. - -- [ ] **Step 2: Confirm only allowed paths are touched** - -Run: - -```sh -git diff --name-only | grep -vE "^submodules/" | head -5 -git diff --name-only | grep -E "^submodules/(Postbox|TelegramCore|TelegramApi)/" | head -5 -``` - -Both expected: empty. If either has output: HALT — rogue changes exist; investigate before committing. - -- [ ] **Step 3: Stage only the modified Swift and BUILD files** - -Run: - -```sh -git add $(git diff --name-only) -git status --short -``` - -Expected: all changes staged with `M`. Untracked dirs (`build-system/tulsi/`, `submodules/TgVoip/`, `third-party/libx264/`) and the `m` submodule marker remain untouched. - -- [ ] **Step 4: Commit with the wave message** - -Substitute `` with the count from `/tmp/wave106-final-droplist.txt`, `` with the total restored across iterations, and `` with the BUILD deps removed (0 if Task 9 skipped). - -```sh -N=$(wc -l < /tmp/wave106-final-droplist.txt | tr -d ' ') -M=$(cat /tmp/wave106-restore-iter*.txt 2>/dev/null | sort -u | wc -l | tr -d ' ') -K=$(git diff --cached --name-only | grep -c BUILD) - -git commit -m "$(cat < TelegramEngine wave 106 (import drop sweep round 2) - -Speculative drop of \`import Postbox\` in $N files where the last -Postbox-typed symbol reference was peeled off by waves 94-105. -Methodology: pattern-based pre-flight skip + drop + build-feedback -restore loop (wave-93-validated recipe). $M files restored after build. -$K BUILD deps removed. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -git status --short -``` - -Expected: clean commit, working tree returns to the known-persistent untracked-only state. - ---- - -## Task 11: Update memory file with wave outcome - -**File:** `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md`. - -- [ ] **Step 1: Read the current memory file to find the recent-commits section** - -Read the top section listing wave commits. - -- [ ] **Step 2: Insert a wave-106 line below the wave-105 entry** - -Append (substituting the actual commit hash from `git log -1 --format=%H | head -c 10`): - -```markdown -- `` — wave 106: speculative `import Postbox` drop sweep round 2. files dropped, restored after build feedback. Methodology re-run of wave 93 (`72de7c4fd5`) with expanded pre-flight regex (added bare-name escapes `\bPeer\b`/`\bMessage\b`/`\bMedia\b` per wave-93 lesson). BUILD deps removed. build cycles. -``` - -Update the memory file's `description:` frontmatter to reflect wave 106 as the latest. - ---- - -## Halt-and-revert recipe (if anything goes seriously wrong) - -If at any point the build fails in TelegramCore/Postbox/TelegramApi, or iteration count exceeds 5 with non-trivial residue, or scope drifts beyond the spec: - -```sh -git checkout -- submodules/ -git status --short # should match the pre-flight expected output -``` - -The wave is fully reversible until Task 10 commits. - ---- - -## Plan Self-Review Notes - -- **Spec coverage:** Tasks 1-11 map 1:1 to the 8-step procedure in the spec plus pre-flight (Task 1) and post-commit memory update (Task 11). Halt conditions appear in Tasks 5/7/9 and the final halt-and-revert recipe. -- **Placeholder scan:** No TBDs/TODOs. All ``/``/``/`` are explicitly substituted via shell expansion in Step 4 of Task 10. -- **Type/method consistency:** Single-purpose tasks operating on filesystem and grep — no method-name drift risk. -- **Iteration shape:** Tasks 5-7 form the iteration loop; Task 8 is the validation gate; Task 9 is optional housekeeping; Task 10 commits. diff --git a/docs/superpowers/plans/2026-04-26-postbox-wave-106-pivot-engine-data-incremental.md b/docs/superpowers/plans/2026-04-26-postbox-wave-106-pivot-engine-data-incremental.md deleted file mode 100644 index fdf4edb3b9..0000000000 --- a/docs/superpowers/plans/2026-04-26-postbox-wave-106-pivot-engine-data-incremental.md +++ /dev/null @@ -1,223 +0,0 @@ -# Wave 106 Pivot: engine `data(resource:incremental:)` facade extension + 1-site drain - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Pivot wave 106 from the abandoned import-drop sweep to a small facade-extension wave. Add an `incremental: Bool = false` parameter to `TelegramEngine.Resources.data(resource:)`, drain the 1 live `account.postbox.mediaBox.resourceData(..., option: .incremental(...))` consumer site (`ChatInterfaceStateContextMenus.swift:1327`). - -**Background — wave 106 (pure sweep) abandoned 2026-04-26:** Inventory of 576 candidate files showed every one of them legitimately references at least one Postbox-tier token (Postbox/MediaBox/MediaResource/protocol-Peer/protocol-Message/protocol-Media/typealiased identifier). Wave 93's pure import-sweep pattern is exhausted at the file granularity — no single-file orphans remain. See spec `docs/superpowers/specs/2026-04-26-postbox-wave-106-import-drop-sweep-design.md` (committed) for the abandoned methodology. - -**Architecture:** Wave-shape G (facade addition + small validation drain). 2 file edits (1 in TelegramCore, 1 in TelegramUI). Single-iter expected. - -**Tech Stack:** Swift, Bazel via `Make.py`, no unit tests. Verification is the full-project debug-sim-arm64 build. - -**Iteration budget:** 1-2 (TelegramCore touch incurs ~210-260s build). - ---- - -## File Structure - -| File | Role | Edits | -|---|---|---| -| `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift` | Add `incremental` param to `data(resource:)` facade | 1 Edit (signature + body) | -| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Migrate L1327 call site | 1 Edit (call + `data.complete` → `data.isComplete`) | - ---- - -## Task 1: Extend the engine `data(resource:)` facade with `incremental:` parameter - -**File:** `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift` - -- [ ] **Step 1: Edit the `data(resource:)` facade signature and body** - -Find at line 453-466: - -```swift - public func data( - resource: EngineMediaResource, - pathExtension: String? = nil, - waitUntilFetchStatus: Bool = false, - attemptSynchronously: Bool = false - ) -> Signal { - return self.account.postbox.mediaBox.resourceData( - resource._asResource(), - pathExtension: pathExtension, - option: .complete(waitUntilFetchStatus: waitUntilFetchStatus), - attemptSynchronously: attemptSynchronously - ) - |> map { EngineMediaResource.ResourceData($0) } - } -``` - -Replace with: - -```swift - public func data( - resource: EngineMediaResource, - pathExtension: String? = nil, - waitUntilFetchStatus: Bool = false, - incremental: Bool = false, - attemptSynchronously: Bool = false - ) -> Signal { - let option: MediaBoxFetchDataOption = incremental - ? .incremental(waitUntilFetchStatus: waitUntilFetchStatus) - : .complete(waitUntilFetchStatus: waitUntilFetchStatus) - return self.account.postbox.mediaBox.resourceData( - resource._asResource(), - pathExtension: pathExtension, - option: option, - attemptSynchronously: attemptSynchronously - ) - |> map { EngineMediaResource.ResourceData($0) } - } -``` - -The `incremental` parameter is inserted between `waitUntilFetchStatus` and `attemptSynchronously`. Existing call sites passing only labeled-or-trailing arguments remain compatible because Swift requires labels for these (no positional ordering issue). - ---- - -## Task 2: Migrate the consumer call site - -**File:** `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` - -- [ ] **Step 1: Edit L1327 — replace `account.postbox.mediaBox.resourceData(...)` with engine facade** - -Find at line 1327: - -```swift - let _ = (context.account.postbox.mediaBox.resourceData(largest.resource, option: .incremental(waitUntilFetchStatus: false)) -``` - -Replace with: - -```swift - let _ = (context.engine.resources.data(resource: EngineMediaResource(largest.resource), incremental: true) -``` - -- [ ] **Step 2: Rename downstream `data.complete` field access to `data.isComplete`** - -Find at line 1330: - -```swift - if data.complete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { -``` - -Replace with: - -```swift - if data.isComplete, let imageData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { -``` - -The `.path` field name is unchanged (both `MediaResourceData` and `EngineMediaResource.ResourceData` use `path`). - ---- - -## Task 3: Verify residue and build - -- [ ] **Step 1: Residue grep for the migrated expression** - -Run: - -```sh -grep -nE "context\.account\.postbox\.mediaBox\.resourceData\(.*option: \.incremental" submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift -``` - -Expected: empty. - -- [ ] **Step 2: Verify the new facade signature compiles without breaking existing callers** - -Existing call sites of `engine.resources.data(resource:)` use these forms (per wave 32+ history): -- `engine.resources.data(resource: EngineMediaResource(x))` — default args, fine -- `engine.resources.data(resource: EngineMediaResource(x), pathExtension: "ext")` — labeled, fine -- `engine.resources.data(resource: EngineMediaResource(x), waitUntilFetchStatus: true)` — labeled, fine - -Adding `incremental: Bool = false` between `waitUntilFetchStatus` and `attemptSynchronously` doesn't reorder existing call sites because all parameters use labels. Confirm with grep: - -```sh -grep -rnE "engine\.resources\.data\(resource:" submodules --include="*.swift" | wc -l -``` - -Just for visibility — number of existing call sites that should remain green. - -- [ ] **Step 3: Run the full clean build** - -Run: - -```sh -source ~/.zshrc 2>/dev/null && \ -python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ - --configuration=debug_sim_arm64 2>&1 | tail -30 -``` - -Expected: build success, no errors. If failure, fix in place and re-run (single iter expected). - ---- - -## Task 4: Commit the wave - -- [ ] **Step 1: Inspect diff** - -Run: - -```sh -git diff --stat -``` - -Expected: 2 files modified. - -- [ ] **Step 2: Stage and commit** - -```sh -git add submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift \ - submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift - -git commit -m "$(cat <<'EOF' -Postbox -> TelegramEngine wave 106 (pivot: engine data() incremental facade + 1-site drain) - -Original wave 106 pure import-drop sweep abandoned: 576 candidate files -all genuinely reference Postbox-tier tokens; wave 93's pattern exhausted -at file granularity (no single-file orphans remain). - -Pivot: extend engine.resources.data(resource:) facade with -`incremental: Bool = false` parameter. Drain the 1 live consumer site -(ChatInterfaceStateContextMenus:1327) plus consumer-side -`data.complete` -> `data.isComplete` rename. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Update memory file - -**File:** `/Users/isaac/.claude/projects/-Users-isaac-build-telegram-telegram-ios/memory/project_postbox_refactor_next_wave.md` - -- [ ] **Step 1: Update frontmatter description and append wave 106 entries** - -Update the `description:` line to reflect wave 106 (pivot). - -Append two lines under the recent-commits section: -- One for the abandoned wave 106 import-drop sweep with the key finding (sweep pattern exhausted, save future sessions a re-attempt). -- One for the wave 106 pivot commit hash with cost/yield. - -- [ ] **Step 2: Append a "Wave 106 ABANDONED" subsection** documenting the import-sweep exhaustion finding so future sessions don't re-attempt the pure sweep shape. Note the regex set tested and the conclusion ("any consumer file with `import Postbox` legitimately needs at least one Tier-1/Tier-2 token"). - ---- - -## Halt-and-revert recipe - -If build fails for non-trivial reasons (more than 1 iter): - -```sh -git checkout -- submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift \ - submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift -git status --short -``` - -Wave is reversible until Task 4 commits. diff --git a/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md b/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md deleted file mode 100644 index c2e2c5d5f3..0000000000 --- a/docs/superpowers/plans/2026-04-30-typing-draft-send-delay.md +++ /dev/null @@ -1,703 +0,0 @@ -# Typing-Draft Send Delay Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Park outgoing messages in `PendingMessageManager` after their content is uploaded, releasing them only when the typing-draft for the matching `(peerId, threadId)` clears. - -**Architecture:** Add a per-account Postbox view (`.allTypingDrafts`) that exposes the live `Set` of currently-active typing drafts. Subscribe once at `PendingMessageManager.init`. Add a parked state (`.waitingForSendGate`) to `PendingMessageState` and a forward parking lot dictionary on the manager. Gate at three sites (single-send, album-send, forward-send); drain on view updates that remove keys. - -**Tech Stack:** Swift, Bazel, Postbox view system, SwiftSignalKit. - -**Spec:** `docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md` - -**Build verification command (used by every task that compiles code):** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -This codebase has **no unit tests** (per `CLAUDE.md`). Verification is "full build green" at each step plus manual exercise after final task. - ---- - -## File Structure - -**Files created:** -- `submodules/Postbox/Sources/AllTypingDraftsView.swift` — new Postbox view tracking the set of active typing-draft keys. - -**Files modified:** -- `submodules/Postbox/Sources/Views.swift` — register the new view key. -- `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` — gate insertion, drain, subscription, parked-state plumbing. - -**No BUILD edits needed:** Postbox `BUILD` uses `glob(["Sources/**/*.swift"])`, so the new file is auto-picked up. - ---- - -## Task 1: Add `MutableAllTypingDraftsView` / `AllTypingDraftsView` - -**Files:** -- Create: `submodules/Postbox/Sources/AllTypingDraftsView.swift` - -This task adds the Postbox view file. It is **not yet wired** into `PostboxViewKey` (Task 2), so this commit alone will not change behavior. - -- [ ] **Step 1: Create `AllTypingDraftsView.swift`** - -```swift -import Foundation - -final class MutableAllTypingDraftsView: MutablePostboxView { - fileprivate var keys: Set - - init(postbox: PostboxImpl) { - self.keys = Set(postbox.currentTypingDrafts.keys) - } - - func replay(postbox: PostboxImpl, transaction: PostboxTransaction) -> Bool { - if transaction.updatedTypingDrafts.isEmpty { - return false - } - var updated = false - for (key, update) in transaction.updatedTypingDrafts { - if update.value != nil { - if self.keys.insert(key).inserted { - updated = true - } - } else { - if self.keys.remove(key) != nil { - updated = true - } - } - } - return updated - } - - func refreshDueToExternalTransaction(postbox: PostboxImpl) -> Bool { - let new = Set(postbox.currentTypingDrafts.keys) - if new == self.keys { - return false - } - self.keys = new - return true - } - - func immutableView() -> PostboxView { - return AllTypingDraftsView(self) - } -} - -public final class AllTypingDraftsView: PostboxView { - public let keys: Set - - init(_ view: MutableAllTypingDraftsView) { - self.keys = view.keys - } -} -``` - -- [ ] **Step 2: Verify the build (file is unwired, so it must compile standalone)** - -Run the build command from the header. Expected: **success**. Common failure modes: `currentTypingDrafts` access scope (already `fileprivate(set)`, accessible from same-module file); `transaction.updatedTypingDrafts` shape (already `[PeerAndThreadId: PostboxImpl.TypingDraftUpdate]` per `PostboxTransaction.swift:59`). - -- [ ] **Step 3: Commit** - -```bash -git add submodules/Postbox/Sources/AllTypingDraftsView.swift -git commit -m "Postbox: add AllTypingDraftsView tracking Set of active typing drafts" -``` - ---- - -## Task 2: Register `.allTypingDrafts` in `PostboxViewKey` - -**Files:** -- Modify: `submodules/Postbox/Sources/Views.swift` - -- [ ] **Step 1: Add the case to the enum** - -In `submodules/Postbox/Sources/Views.swift` line 105, append `.allTypingDrafts` after `.typingDrafts(...)`: - -```swift - case typingDrafts(PeerAndThreadId) - case allTypingDrafts -``` - -- [ ] **Step 2: Add hash arm** - -In the `hash(into:)` switch (the new arm goes alongside the existing `case let .typingDrafts(peerId):` arm at line 232–233): - -```swift - case let .typingDrafts(peerId): - hasher.combine(peerId) - case .allTypingDrafts: - hasher.combine(22) -``` - -The constant `22` is the next free hash-tag (existing tags use 0–21). - -- [ ] **Step 3: Add `==` arm** - -In the `==(lhs:rhs:)` switch (line 551–556 area): - -```swift - case let .typingDrafts(peerId): - if case .typingDrafts(peerId) = rhs { - return true - } else { - return false - } - case .allTypingDrafts: - if case .allTypingDrafts = rhs { - return true - } else { - return false - } -``` - -- [ ] **Step 4: Wire `postboxViewForKey`** - -In the `postboxViewForKey(postbox:key:)` switch (line 684–685 area): - -```swift - case let .typingDrafts(peerId): - return MutableTypingDraftsView(postbox: postbox, peerAndThreadId: peerId) - case .allTypingDrafts: - return MutableAllTypingDraftsView(postbox: postbox) - } -} -``` - -- [ ] **Step 5: Verify the build** - -Run the build command. Expected: **success**. The view is now constructible but no consumer subscribes yet. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/Postbox/Sources/Views.swift -git commit -m "Postbox: wire .allTypingDrafts view key into PostboxViewKey" -``` - ---- - -## Task 3: Add `.waitingForSendGate` state and update related switches - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -This task introduces the new `PendingMessageState` case and extends every switch that needs to know about it. No gate insertion happens yet — the new case is unreachable, so behavior is unchanged. - -- [ ] **Step 1: Add the case to `PendingMessageState`** - -Edit `private enum PendingMessageState` (line 28). After the `.waitingToBeSent` case, add: - -```swift - case waitingToBeSent(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) - case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) -``` - -- [ ] **Step 2: Extend the `groupId` computed property** - -In the same `var groupId: Int64?` switch (line 37–54), add an arm right after the `.waitingToBeSent` arm: - -```swift - case let .waitingToBeSent(groupId, _): - return groupId - case let .waitingForSendGate(groupId, _): - return groupId -``` - -- [ ] **Step 3: Extend `dataForPendingMessageGroup` to accept parked members as ready** - -Find `private func dataForPendingMessageGroup(_ groupId: Int64)` (line 753). After the `.waitingToBeSent` arm, add a parallel arm: - -```swift - case let .waitingToBeSent(contextGroupId, content): - if contextGroupId == groupId { - result.append((context, id, content)) - } - case let .waitingForSendGate(contextGroupId, content): - if contextGroupId == groupId { - result.append((context, id, content)) - } -``` - -This lets a partially-parked album drain correctly once the gate opens. - -- [ ] **Step 4: Exclude parked state from `updatePendingMediaUploads`** - -Find `private func updatePendingMediaUploads()` (line 262). Inside its `switch context.state { ... }`, the existing arms cover `.waitingForUploadToStart` and `.uploading`. Parked state is post-upload and should NOT report progress. The switch already has a `default: break` — `.waitingForSendGate` falls through it automatically. **No edit required**, but verify by re-reading the function after the previous edits compile. - -- [ ] **Step 5: Verify the build** - -Run the build command. Expected: **success**. Swift will reject the build if any `switch context.state { ... }` becomes non-exhaustive — fix any such switches by adding a `case .waitingForSendGate: ...` arm matching the closest existing parallel state. Likely candidates: - -- `private enum PendingMessageState`'s `groupId` switch — handled in Step 2. -- `dataForPendingMessageGroup` switch — handled in Step 3. -- The forward branch in `beginSendingMessages` (line ~614, doesn't switch directly). -- Any other `switch .state` blocks (search with `grep -n "switch.*\.state" submodules/TelegramCore/Sources/State/PendingMessageManager.swift`). - -If the compiler reports a non-exhaustive switch elsewhere, add an arm that mirrors the `.waitingToBeSent` arm in that switch, or `case .waitingForSendGate: break` if the new state is irrelevant to that site. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: add .waitingForSendGate state and update related switches" -``` - ---- - -## Task 4: Add manager-level subscription and parked-state fields - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -Adds the dictionary, the disposable, the subscription in `init`, the disposal in `deinit`, and a stub `handleLiveTypingDraftsUpdate` that only stores the set (drain is wired in Task 5). - -- [ ] **Step 1: Add stored fields** - -In `public final class PendingMessageManager` (line 205), after the existing `private var pendingMessageIds = Set()` (line 232), add: - -```swift - private var pendingMessageIds = Set() - private var liveTypingDraftKeys: Set = [] - private let allTypingDraftsDisposable = MetaDisposable() - private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:] - private let beginSendingMessagesDisposables = DisposableSet() -``` - -(The first and last lines are already present; the three new lines insert between them.) - -- [ ] **Step 2: Subscribe in `init`** - -In `init(network:postbox:accountPeerId:auxiliaryMethods:stateManager:localInputActivityManager:messageMediaPreuploadManager:revalidationContext:)` (line 243), after the field assignments (line 252), add: - -```swift - self.revalidationContext = revalidationContext - - let queue = self.queue - self.allTypingDraftsDisposable.set( - (postbox.combinedView(keys: [.allTypingDrafts]) - |> deliverOn(queue)).start(next: { [weak self] view in - self?.handleLiveTypingDraftsUpdate(view) - }) - ) - } -``` - -- [ ] **Step 3: Dispose in `deinit`** - -In `deinit` (line 255–260), append: - -```swift - deinit { - self.beginSendingMessagesDisposables.dispose() - for (_, disposable) in self.newTopicDisposables { - disposable.dispose() - } - self.allTypingDraftsDisposable.dispose() - } -``` - -- [ ] **Step 4: Add the handler (no drain yet)** - -Add this new private method anywhere in the class (e.g. just before `private func updatePendingMediaUploads()` at line 262): - -```swift - private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) { - assert(self.queue.isCurrent()) - - let new: Set - if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView { - new = view.keys - } else { - new = [] - } - self.liveTypingDraftKeys = new - } -``` - -This handler is functional — it tracks the live key set correctly. Task 5 augments it to also fire `drainSendGate` for keys that just cleared. - -- [ ] **Step 5: Verify the build** - -Run the build command. Expected: **success**. `CombinedView` is in scope because `Postbox` is already imported at the top of the file. `MetaDisposable` and `Queue` come from `SwiftSignalKit` (already imported). - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: subscribe to .allTypingDrafts and add parking-lot fields" -``` - ---- - -## Task 5: Add gate predicates, drain, and wire the single-message gate - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -Combined task: introduces the predicates, the `drainSendGate` body, augments `handleLiveTypingDraftsUpdate` to call drain, and wires the **first** insertion site (single-message). After this task, single non-grouped messages park correctly when the peer is live-typing and unpark when the draft clears. - -- [ ] **Step 1: Add `shouldGateSend` and `isSendGateOpen` private helpers** - -Anywhere in the `PendingMessageManager` class (group them next to `handleLiveTypingDraftsUpdate` from Task 4): - -```swift - private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool { - if messageId.namespace == Namespaces.Message.ScheduledCloud { - return false - } - if messageId.peerId.namespace == Namespaces.Peer.SecretChat { - return false - } - if messageId.peerId == self.accountPeerId { - return false - } - if threadId == Message.newTopicThreadId { - return false - } - return true - } - - private func isSendGateOpen(for key: PeerAndThreadId) -> Bool { - return !self.liveTypingDraftKeys.contains(key) - } -``` - -- [ ] **Step 2: Augment `handleLiveTypingDraftsUpdate` to call drain** - -Replace the body of `handleLiveTypingDraftsUpdate(_:)` (added in Task 4) with the cleared-key drain logic: - -```swift - private func handleLiveTypingDraftsUpdate(_ combined: CombinedView) { - assert(self.queue.isCurrent()) - - let new: Set - if let view = combined.views[.allTypingDrafts] as? AllTypingDraftsView { - new = view.keys - } else { - new = [] - } - let cleared = self.liveTypingDraftKeys.subtracting(new) - self.liveTypingDraftKeys = new - for key in cleared { - self.drainSendGate(key: key) - } - } -``` - -- [ ] **Step 3: Add the `drainSendGate` body** - -Add this new private method next to `handleLiveTypingDraftsUpdate`: - -```swift - private func drainSendGate(key: PeerAndThreadId) { - assert(self.queue.isCurrent()) - - // (1) Single-message drain: snapshot then commit in messageId.id order. - var singleDrains: [(context: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)] = [] - for (id, context) in self.messageContexts { - if id.peerId != key.peerId { - continue - } - if context.threadId != key.threadId { - continue - } - if case let .waitingForSendGate(groupId, content) = context.state, groupId == nil { - singleDrains.append((context, id, content)) - } - } - singleDrains.sort(by: { $0.messageId.id < $1.messageId.id }) - for entry in singleDrains { - self.commitSendingSingleMessage(messageContext: entry.context, messageId: entry.messageId, content: entry.content) - } - - // (2) Grouped-album drain: collect distinct groupIds whose members match the key, - // iterate ascending by min messageId.id, fire commitSendingMessageGroup. - var groupKeys: [(groupId: Int64, minMessageId: Int32)] = [] - var seenGroupIds = Set() - for (id, context) in self.messageContexts { - if id.peerId != key.peerId { - continue - } - if context.threadId != key.threadId { - continue - } - if case let .waitingForSendGate(groupId, _) = context.state, let groupId = groupId { - if !seenGroupIds.contains(groupId) { - seenGroupIds.insert(groupId) - groupKeys.append((groupId, id.id)) - } else { - if let index = groupKeys.firstIndex(where: { $0.groupId == groupId }), id.id < groupKeys[index].minMessageId { - groupKeys[index].minMessageId = id.id - } - } - } - } - groupKeys.sort(by: { $0.minMessageId < $1.minMessageId }) - for (groupId, _) in groupKeys { - if let data = self.dataForPendingMessageGroup(groupId) { - self.commitSendingMessageGroup(groupId: groupId, messages: data) - } - } - - // (3) Forward drain: pop parked groups for this key in FIFO order; fire each. - if let parkedGroups = self.forwardSendGateGroups.removeValue(forKey: key) { - for messages in parkedGroups { - for (context, _, _) in messages { - context.state = .sending(groupId: nil) - } - let sendMessage: Signal = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { data in - let (_, message, forwardInfo) = data - return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) - }) - |> map { _ -> PendingMessageResult in - return .progress(1.0) - } - messages[0].0.sendDisposable.set((sendMessage - |> deliverOn(self.queue)).start()) - } - } - - self.updateWaitingUploads(peerId: key.peerId) - self.updatePendingMediaUploads() - } -``` - -The forward dispatch block mirrors the existing forward-fire code at lines 719–731 of `PendingMessageManager.swift`. Keep them in sync if either changes. - -- [ ] **Step 4: Wire the single-message gate at `beginSendingMessage`** - -Replace `private func beginSendingMessage(messageContext:messageId:groupId:content:)` (line 738) with: - -```swift - private func beginSendingMessage(messageContext: PendingMessageContext, messageId: MessageId, groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) { - if let groupId = groupId { - messageContext.state = .waitingToBeSent(groupId: groupId, content: content) - } else { - let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId) - if self.shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !self.isSendGateOpen(for: key) { - messageContext.state = .waitingForSendGate(groupId: nil, content: content) - } else { - self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) - } - } - self.updatePendingMediaUploads() - } -``` - -- [ ] **Step 5: Verify the build** - -Run the build command. Expected: **success**. - -- [ ] **Step 6: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: wire typing-draft gate at single-message send + drain" -``` - ---- - -## Task 6: Wire the album-send gate at `commitSendingMessageGroup` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -- [ ] **Step 1: Replace `commitSendingMessageGroup`** - -Replace `private func commitSendingMessageGroup(groupId:messages:)` (line 794) with: - -```swift - private func commitSendingMessageGroup(groupId: Int64, messages: [(messageContext: PendingMessageContext, messageId: MessageId, content: PendingMessageUploadedContentAndReuploadInfo)]) { - let firstMessageId = messages[0].messageId - let firstThreadId = messages[0].messageContext.threadId - let key = PeerAndThreadId(peerId: firstMessageId.peerId, threadId: firstThreadId) - if self.shouldGateSend(messageId: firstMessageId, threadId: firstThreadId) && !self.isSendGateOpen(for: key) { - for entry in messages { - entry.messageContext.state = .waitingForSendGate(groupId: groupId, content: entry.content) - } - return - } - - for (context, _, _) in messages { - context.state = .sending(groupId: groupId) - } - let sendMessage: Signal = self.sendGroupMessagesContent(network: self.network, postbox: self.postbox, stateManager: self.stateManager, accountPeerId: self.accountPeerId, group: messages.map { ($0.1, $0.2) }) - |> map { next -> PendingMessageResult in - return .progress(1.0) - } - messages[0].0.sendDisposable.set((sendMessage - |> deliverOn(self.queue)).start()) - } -``` - -Album members share `(peerId, threadId)` by construction (the album fires once every member is post-upload in the same `groupId`). - -- [ ] **Step 2: Verify the build** - -Run the build command. Expected: **success**. - -- [ ] **Step 3: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: wire typing-draft gate at album send" -``` - ---- - -## Task 7: Wire the forward-send gate inside `beginSendingMessages` - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -- [ ] **Step 1: Replace the forward-dispatch loop** - -The existing forward-dispatch loop is at lines 714–733 inside `beginSendingMessages`. Replace exactly the body of `for messages in countedMessageGroups { ... }` with the gate-aware version: - -```swift - for messages in countedMessageGroups { - if messages.isEmpty { - continue - } - - let firstMessage = messages[0].1 - let key = PeerAndThreadId(peerId: firstMessage.id.peerId, threadId: firstMessage.threadId) - if strongSelf.shouldGateSend(messageId: firstMessage.id, threadId: firstMessage.threadId) && !strongSelf.isSendGateOpen(for: key) { - for (context, _, forwardInfo) in messages { - context.state = .waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) - } - if strongSelf.forwardSendGateGroups[key] == nil { - strongSelf.forwardSendGateGroups[key] = [] - } - strongSelf.forwardSendGateGroups[key]!.append(messages) - continue - } - - for (context, _, _) in messages { - context.state = .sending(groupId: nil) - } - - let sendMessage: Signal = strongSelf.sendGroupMessagesContent(network: strongSelf.network, postbox: strongSelf.postbox, stateManager: strongSelf.stateManager, accountPeerId: strongSelf.accountPeerId, group: messages.map { data in - let (_, message, forwardInfo) = data - return (message.id, PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil)) - }) - |> map { next -> PendingMessageResult in - return .progress(1.0) - } - messages[0].0.sendDisposable.set((sendMessage - |> deliverOn(strongSelf.queue)).start()) - } -``` - -The non-gated branch is the existing code, copied verbatim. The gated branch parks every context in `.waitingForSendGate` and appends the whole tuple-array onto `forwardSendGateGroups[key]`. The drain in `Task 5` reads from this dict. - -- [ ] **Step 2: Verify the build** - -Run the build command. Expected: **success**. - -- [ ] **Step 3: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: wire typing-draft gate at forward send" -``` - ---- - -## Task 8: Clean up parked forwards in `updatePendingMessageIds` removal loop - -**Files:** -- Modify: `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` - -When a message is removed from `pendingMessageIds` (e.g. user discarded it, or it was force-deleted), the existing loop disposes context-level state. Parked single/album state lives on the `PendingMessageContext` and gets reset when `state = .none`. Parked forwards live in `forwardSendGateGroups`, which the existing loop does not touch — patch that here. - -- [ ] **Step 1: Add cleanup for `forwardSendGateGroups` in `updatePendingMessageIds`** - -In `updatePendingMessageIds(_:)` (line 284), after the `for id in removedMessageIds { ... }` loop (closes around line 323), add a forward-cleanup pass before `if !addedMessageIds.isEmpty { ... }`: - -```swift - if !removedMessageIds.isEmpty && !self.forwardSendGateGroups.isEmpty { - for (key, parkedGroups) in self.forwardSendGateGroups { - var rebuilt: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]] = [] - for group in parkedGroups { - let filtered = group.filter { entry in - return !removedMessageIds.contains(entry.1.id) - } - if !filtered.isEmpty { - rebuilt.append(filtered) - } - } - if rebuilt.isEmpty { - self.forwardSendGateGroups.removeValue(forKey: key) - } else { - self.forwardSendGateGroups[key] = rebuilt - } - } - } -``` - -- [ ] **Step 2: Verify the build** - -Run the build command. Expected: **success**. - -- [ ] **Step 3: Commit** - -```bash -git add submodules/TelegramCore/Sources/State/PendingMessageManager.swift -git commit -m "PendingMessageManager: cleanup parked forwards on pending-message removal" -``` - ---- - -## Task 9: Manual verification - -**Files:** None (verification-only). - -This codebase has no unit tests, so the final acceptance gate is a manual exercise on a debug simulator build. Skip if you don't have a working two-device test setup; in that case, re-confirm only the build is green. - -- [ ] **Step 1: Confirm full build is still green** - -Run the build command from the header. Expected: **success**. - -- [ ] **Step 2: 1:1 chat, text-send delay** - -Setup: log in to two real Telegram accounts on two devices/simulators (account A and account B). Open the A↔B 1:1 chat on both. - -- On B, begin live-typing a draft (Telegram clients with the live-typing-drafts feature emit the typing-draft updates; if B doesn't expose live drafts, simulate by triggering whatever flow populates `transaction.combineTypingDrafts(...)` in your test environment). -- On A, immediately type and send a text message. Confirm: the message appears with a "sending" indicator and does **not** complete until B's draft clears or commits. Once B's draft is cleared, A's message sends within ~1 second. - -- [ ] **Step 3: Album / grouped-media delay** - -Repeat Step 2 with an album of two photos sent from A while B is live-typing. Expected: all album members upload (you can see progress finish), then all sit parked at "sending" until the gate opens. - -- [ ] **Step 4: Forward delay** - -Repeat with a forwarded message (forward a message from a third chat into A↔B while B is live-typing). Expected: forward parks until the gate opens. - -- [ ] **Step 5: Negative — Saved Messages skip** - -In Saved Messages (chat with self), send any message. Confirm: never delays, regardless of typing-draft state. There should be no typing draft for the self peer in the first place — this is just an existence check that the skip-rule does its job. - -- [ ] **Step 6: Negative — secret chat skip** - -In a secret chat, send a message. Confirm: never delays. Server-side, secret chats don't emit typing-draft updates — this verifies the explicit skip-check. - -- [ ] **Step 7: Negative — scheduled message skip (defensive)** - -The `Namespaces.Message.ScheduledCloud` skip in `shouldGateSend` is defensive — in practice, scheduled messages are stored in the scheduled queue and only enter `PendingMessageManager` at delivery time, by which point they've been re-created with cloud namespace. Verifying the defensive branch directly is awkward and not strictly required. If you have an instrumentation path that forces a scheduled-namespace message through `beginSendingMessages`, confirm it doesn't park. - -- [ ] **Step 8: Multi-thread — gate is per-thread** - -In a forum/topic group, on B begin live-typing in topic 1 only. On A, send a message to topic 2 of the same group. Expected: A's message to topic 2 is **not** delayed. - ---- - -## Self-review notes (already applied inline) - -- **Spec coverage:** every section of `2026-04-30-typing-draft-send-delay-design.md` maps to a task — Postbox view (Tasks 1–2), state machine extension (Task 3), subscription (Task 4), predicates + drain + single-send (Task 5), album (Task 6), forwards (Task 7), removal cleanup (Task 8), manual verification (Task 9). -- **Type names verified against actual code:** `PendingMessageState`, `PendingMessageContext`, `PendingMessageUploadedContentAndReuploadInfo`, `ForwardSourceInfoAttribute`, `PendingMessageResult`, `Signal`, `MetaDisposable`, `Queue`, `CombinedView`, `Namespaces.Message.ScheduledCloud`, `Namespaces.Peer.SecretChat`, `Message.newTopicThreadId`, `PeerAndThreadId`, `combineTypingDrafts`, `currentTypingDrafts`, `transaction.updatedTypingDrafts`, `update.value`. All names match the source. -- **`postponeSending` (paid-message) interaction:** untouched. Composes via state-machine ordering (paid postpone gates upload-start; this gate sits post-upload). -- **No unused-private-function warnings:** every helper is introduced together with its first caller (predicates + drain + first call site combined in Task 5). diff --git a/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md b/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md deleted file mode 100644 index 39fb15075b..0000000000 --- a/docs/superpowers/plans/2026-05-01-groupref-ssrc-discovery.md +++ /dev/null @@ -1,1121 +0,0 @@ -# GroupInstanceReferenceImpl Reactive SSRC Discovery — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `GroupInstanceReferenceImpl`'s test-SFU-only `ActiveAudioSsrcs` discovery path with a per-receiver `webrtc::FrameTransformerInterface` that observes incoming SSRCs at the depacketizer-to-decoder boundary, buffers their first ≤1 s of frames, drives an audio recvonly transceiver to be added via the existing `_requestMediaChannelDescriptions` callback, and flushes the buffer once renegotiation completes — restoring audio in real Telegram group calls without a startup gap. - -**Architecture:** A single `GRAudioFrameTransformer` instance is installed on every audio receiver in this `GroupInstanceReferenceInternal` (mid=0 sendrecv outgoing, plus each recvonly transceiver as it's added). It maintains a per-SSRC state machine (`kBuffering` → `kDrained`) and exposes `releaseSsrc(ssrc)` for the discovery flow to call after `onRenegotiationComplete`. A 250 ms debounce coalesces bursts into one renegotiation. The `ActiveAudioSsrcs` data-channel mechanism (test-SFU only) is removed from both the Go SFU and the C++ ReferenceImpl so the tap is the single discovery path in test and production. - -**Tech Stack:** C++17, WebRTC `FrameTransformerInterface` API, Bazel 8.4.2, the existing tgcalls test bench (Go/Pion SFU + `tgcalls_cli` integration tests). - ---- - -## Reference: Spec & key call-sites - -- **Spec:** `docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md` -- **WebRTC plumbing reference:** `third-party/webrtc/webrtc/api/frame_transformer_interface.h` (the `FrameTransformerInterface` / `TransformableFrameInterface` / `TransformedFrameCallback` contract); `third-party/webrtc/webrtc/media/engine/webrtc_voice_engine.cc:2240-2266` (the unsignaled→signaled stream promotion path that lets buffered frames flow into the same receive stream). -- **C++ files we modify:** `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` (only `.cpp`; the `.h` doesn't change). -- **Go file we modify:** `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go`. -- **Docs to refresh:** `submodules/TgVoipWebrtc/CLAUDE.md` (ReferenceImpl section), `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` (group-mode SSRC discovery flow), `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` (drop the `ActiveAudioSsrcs` mention). - -## File structure - -| File | Change | Responsibility | -|---|---|---| -| `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` | modify | Add `GRAudioFrameTransformer` (anonymous-namespace), wire into `start()` and `renegotiate()`, add `handleDiscoveredAudioSsrc` / `scheduleDiscoveryRenegotiation`, delete `handleActiveAudioSsrcs` and its dispatch, call `releaseSsrc` in `onRenegotiationComplete`, add `_audioFrameTransformer` and `_discoveryRenegotiationScheduled` members. | -| `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` | modify | Delete `buildActiveSSRCsMessage`, `broadcastActiveSSRCs`, both call-sites (post-Join goroutine line 330, Leave line 1061). The `participantSSRCs` audio-collection helper used by `broadcastActiveSSRCs` is otherwise unused; remove it. | -| `submodules/TgVoipWebrtc/CLAUDE.md` | modify | Update the "GroupInstanceReferenceImpl" section: SSRC discovery now via tap; remove "ActiveAudioSsrcs" reference; add note that `_audioFrameTransformer` is the seam where e2e decrypt will land. | -| `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` | modify | Remove the `ActiveAudioSsrcs` line from the group-mode flow description. | -| `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` | modify | Remove "ActiveAudioSsrcs" from the `sfu.go` bullet. | - -No new files. No iOS code touched. No CLI test-tool code touched (we ride on the existing `--mute-participants` and per-SSRC level invariants from the previous fix). - ---- - -## Test bench reference - -The existing CLI test (built earlier in this branch) is the integration harness. After every code change, the regression sweep is: - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 - -# T1: All-Reference, no mute — must SUCCESS -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -# Expected: "Result: SUCCESS", exit code 0 - -# T2: Mixed (2C+2R) — must SUCCESS -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 2 --reference-participants 2 --duration 6 --quiet - -# T3: All-Reference with P0 muted — must SUCCESS (muted-peer invariant) -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 --quiet - -# T4: Mixed muted (custom side muted) — must SUCCESS -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 1 --reference-participants 2 --mute-participants 0 --duration 6 --quiet - -# T5: Mixed muted (reference side muted) — must SUCCESS -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 2 --reference-participants 1 --mute-participants 2 --duration 6 --quiet -``` - -Tasks below name a specific subset of T1–T5 to run as the verification step. Always run **all five** before the final commit of each task — the muted-peer invariant from the audio-level fix is the strongest detector for routing regressions. - ---- - -### Task 1: Delete `ActiveAudioSsrcs` from the test SFU - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` - -**Why first:** Forces every subsequent test run to exercise the discovery path we're about to build. Until the tap is in, all-Reference tests will fail (expected); mixed tests will still pass (CustomImpl discovers via raw RTP). This intentional regression is the visible TDD-red for the rest of the plan. - -- [ ] **Step 1: Read the current state** - -Confirm the three locations with: - -```bash -grep -n "broadcastActiveSSRCs\|buildActiveSSRCsMessage" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go -``` - -Expected output: -``` -330: s.broadcastActiveSSRCs() -886:// broadcastActiveSSRCs sends the current set of active audio SSRCs to all connected participants. -888:func (s *SFU) broadcastActiveSSRCs() { -911: msg := buildActiveSSRCsMessage(ssrcs) -986:func buildActiveSSRCsMessage(ssrcs []int32) string { -1061: s.broadcastActiveSSRCs() -``` - -- [ ] **Step 2: Delete the two call-sites** - -Remove the line `s.broadcastActiveSSRCs()` at line 330 (inside the post-Join goroutine, immediately above `s.broadcastActiveVideoSSRCs()`). Remove the line `s.broadcastActiveSSRCs()` at line 1061 (inside `Leave`, immediately above `s.broadcastActiveVideoSSRCs()`). - -- [ ] **Step 3: Delete `broadcastActiveSSRCs` and `buildActiveSSRCsMessage`** - -Delete the entire function `broadcastActiveSSRCs` (the doc-comment at line 886 through the closing `}` of the function around line 916, including the `participantSSRCs` map it builds locally). - -Delete the entire function `buildActiveSSRCsMessage` (line 986 through line 996). - -- [ ] **Step 4: Verify nothing else references them** - -```bash -grep -n "broadcastActiveSSRCs\|buildActiveSSRCsMessage\|ActiveAudioSsrcs" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go -``` - -Expected output: empty. - -- [ ] **Step 5: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 6: Run T1 (all-Reference) — confirm RED** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`, `Audio received: 0/3`, `EXIT=1`. (No discovery → no recvonly transceivers → no audio.) - -- [ ] **Step 7: Run T2 (mixed) — confirm partial regression** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 2 --reference-participants 2 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`. CustomImpl peers will still discover ReferenceImpl audio via raw RTP (so they receive ReferenceImpl audio), but ReferenceImpl peers cannot discover anyone (so they receive nothing). `Audio received` will report something less than 4/4. - -- [ ] **Step 8: Commit the intentional regression** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go -git commit -m "$(cat <<'EOF' -test sfu: remove ActiveAudioSsrcs broadcast - -The test-only ActiveAudioSsrcs colibri message is being replaced by -ReferenceImpl's per-receiver FrameTransformer-based discovery (next -commits). Removing the message first forces every subsequent test -run to exercise the new code path — keeping the message in place -would let test passes mask production-real bugs. - -T1/T2 currently FAIL as expected; T3-T5 likewise — the muted-peer -invariant cannot hold without working discovery. All restored by the -end of this PR. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 2: Delete `handleActiveAudioSsrcs` from `GroupInstanceReferenceImpl` - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` - -**Why now:** The handler is now dead code (no SFU sends `ActiveAudioSsrcs` anymore). Removing it before adding the new path means we don't temporarily have two SSRC-add paths that could both insert the same entry into `_remoteSsrcs`. - -- [ ] **Step 1: Confirm the call sites** - -```bash -grep -n "handleActiveAudioSsrcs\|colibriClass.*ActiveAudio" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -``` - -Expected: -``` -1060: if (colibriClass == "ActiveAudioSsrcs") { -1061: handleActiveAudioSsrcs(json); -1063: void handleActiveAudioSsrcs(json11::Json const &json) { -``` - -- [ ] **Step 2: Read the dispatch site** - -Read lines 1048–1067 of `GroupInstanceReferenceImpl.cpp` to confirm the surrounding context of `onDataChannelMessage`. - -- [ ] **Step 3: Remove the dispatch (lines 1056–1067)** - -Inside `onDataChannelMessage`, locate the `colibriClass == "ActiveAudioSsrcs"` branch and the comment immediately above (`// ActiveVideoSsrcs and SenderVideoConstraints are handled by the` ... `// setRequestedVideoChannels() in response.`). Delete the JSON parse block, the colibriClass extraction, and the if-branch that calls `handleActiveAudioSsrcs(json)`. - -The remaining body of `onDataChannelMessage` should consist of the `_dataChannelMessageReceived` forwarding only: - -```cpp - void onDataChannelMessage(std::string const &msg) { - // Forward all data channel messages to the application. - // Audio SSRCs are now discovered by the per-receiver frame - // transformer (see GRAudioFrameTransformer); video channel - // requests are app-driven via setRequestedVideoChannels. - if (_dataChannelMessageReceived) { - _dataChannelMessageReceived(msg); - } - } -``` - -- [ ] **Step 4: Delete `handleActiveAudioSsrcs`** - -Delete the entire function `handleActiveAudioSsrcs(json11::Json const &json)` (starts at the line that previously matched `1063: void handleActiveAudioSsrcs(json11::Json const &json) {`, ends at the matching `}`). It's roughly 60 lines including the diff loop and the `_requestMediaChannelDescriptions` call. - -- [ ] **Step 5: Verify nothing else references it** - -```bash -grep -n "handleActiveAudioSsrcs\|ActiveAudioSsrcs" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -``` - -Expected: empty. - -- [ ] **Step 6: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 7: Run T1 — still RED, same reason** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`, `EXIT=1`. (No regression added beyond Task 1; we just deleted dead code.) - -- [ ] **Step 8: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: remove handleActiveAudioSsrcs - -ActiveAudioSsrcs was a test-SFU-only colibri message. With the test -SFU no longer sending it, the handler is dead code. Removing now -before introducing the new discovery path so we don't briefly have -two paths inserting into _remoteSsrcs. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 3: Add `GRAudioFrameTransformer` skeleton (no behavior yet) - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` - -**Why staged:** Adding the class first as a do-nothing pass-through transformer lets us verify the WebRTC plumbing works (the catch-all transformer never breaks audio, OnTransformedFrame is reachable) before we layer on the per-SSRC state machine. Initially we also install nothing — the class just compiles. - -- [ ] **Step 1: Locate the anonymous namespace insertion point** - -Read lines 109–148 of `GroupInstanceReferenceImpl.cpp` to confirm where the anonymous namespace ends (the `} // anonymous namespace` line). - -- [ ] **Step 2: Add the class right before `} // anonymous namespace`** - -Insert the following code immediately before the closing `} // anonymous namespace` (after `GRCreateSDPObserver`): - -```cpp -// --- Per-receiver audio frame transformer --- -// -// One instance is installed (via `RtpReceiverInterface::SetDepacketizerToDecoderFrameTransformer`) -// on every audio receiver in this GroupInstanceReferenceInternal: -// - mid=0 sendrecv outgoing audio (its receive side acts as the catch-all -// for unsignaled SSRCs); -// - each recvonly audio transceiver added by the SSRC-discovery flow. -// -// Behavior per SSRC: -// - First-sight (no entry yet): notify discovery; buffer the frame. -// - kBuffering (waiting for renegotiation): append to per-SSRC FIFO. -// - kDrained (releaseSsrc has fired): pass through immediately. -// -// `releaseSsrc(ssrc)` is invoked by ReferenceImpl on the media thread -// from `onRenegotiationComplete` once a recvonly transceiver owns the -// SSRC. The transformer drains the per-SSRC FIFO via OnTransformedFrame -// and transitions the entry to kDrained. -// -// `decryptHook` is reserved for the e2e fix (separate PR). Today it is -// nullptr and the transformer just forwards frames unchanged. -class GRAudioFrameTransformer : public webrtc::FrameTransformerInterface { -public: - using SsrcCallback = std::function; - using DecryptHook = std::function; - - GRAudioFrameTransformer(SsrcCallback onNewSsrc, - DecryptHook decrypt, - rtc::Thread* mediaThread) - : _onNewSsrc(std::move(onNewSsrc)), - _decrypt(std::move(decrypt)), - _mediaThread(mediaThread) {} - - // Stub — to be filled in Task 4. - void Transform(std::unique_ptr frame) override { - // Pass-through for now (verifies plumbing). - webrtc::MutexLock lock(&_mu); - const uint32_t ssrc = frame->GetSsrc(); - rtc::scoped_refptr sink; - auto it = _perSsrcSinks.find(ssrc); - if (it != _perSsrcSinks.end()) { - sink = it->second; - } else if (_broadcastSink) { - sink = _broadcastSink; - } - if (!sink) return; - sink->OnTransformedFrame(std::move(frame)); - } - - // Stub — to be filled in Task 4. - void releaseSsrc(uint32_t /*ssrc*/) {} - - void RegisterTransformedFrameCallback( - rtc::scoped_refptr cb) override { - webrtc::MutexLock lock(&_mu); - _broadcastSink = std::move(cb); - } - void RegisterTransformedFrameSinkCallback( - rtc::scoped_refptr cb, - uint32_t ssrc) override { - webrtc::MutexLock lock(&_mu); - _perSsrcSinks[ssrc] = std::move(cb); - } - void UnregisterTransformedFrameCallback() override { - webrtc::MutexLock lock(&_mu); - _broadcastSink = nullptr; - } - void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) override { - webrtc::MutexLock lock(&_mu); - _perSsrcSinks.erase(ssrc); - } - -private: - SsrcCallback _onNewSsrc; - DecryptHook _decrypt; - rtc::Thread* _mediaThread; // identity only; not used for thread-affine asserts in this skeleton - - webrtc::Mutex _mu; - rtc::scoped_refptr _broadcastSink RTC_GUARDED_BY(_mu); - std::map> _perSsrcSinks RTC_GUARDED_BY(_mu); -}; -``` - -- [ ] **Step 3: Add a `webrtc::Mutex` include if missing** - -```bash -grep -n "rtc_base/synchronization/mutex.h\|webrtc::Mutex\b" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp | head -5 -``` - -If the include is missing, add to the includes near the top of the file (after the existing webrtc includes): - -```cpp -#include "rtc_base/synchronization/mutex.h" -``` - -- [ ] **Step 4: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 -``` - -Expected: `INFO: Build completed successfully`. - -If the build fails on `webrtc::Mutex` — try `#include "api/sequence_checker.h"` and use `webrtc::Mutex` from there, or fall back to `std::mutex` (the rest of the file already uses `` via `_audioLevelsMutex` in `tools/cli/group_participant.cpp`; check what's available in `tgcalls/tgcalls/group/`). - -- [ ] **Step 5: Run T1 — still expected to fail (we haven't installed the transformer yet)** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`, `EXIT=1`. The class is defined but unused — same red as Tasks 1–2. - -- [ ] **Step 6: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: add GRAudioFrameTransformer skeleton - -Pass-through frame transformer scaffolding. Behavior (per-SSRC -buffering, releaseSsrc, decrypt hook) lands in the next commit. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 4: Implement the per-SSRC state machine in `GRAudioFrameTransformer` - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` (the class added in Task 3) - -**Why staged:** Once installed (Task 5), this class becomes part of every audio frame's path. Splitting "skeleton" from "behavior" makes the diff small enough to review carefully — bugs here silently drop audio. - -- [ ] **Step 1: Add the `Entry` struct, constants, and `_entries` map to the class** - -Inside `class GRAudioFrameTransformer` (after `_perSsrcSinks` declaration), add: - -```cpp -private: - enum class SsrcState { kBuffering, kDrained }; - - struct Entry { - SsrcState state = SsrcState::kBuffering; - std::deque> buffer; - int64_t firstFrameTimeMs = 0; - }; - - static constexpr int64_t kSsrcDiscoveryTimeoutMs = 1000; - static constexpr size_t kMaxBufferedFramesPerSsrc = 60; - static constexpr size_t kMaxConcurrentBufferedSsrcs = 64; - - void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu); - - std::map _entries RTC_GUARDED_BY(_mu); -``` - -Add `` to the includes if not already present (usually pulled in transitively): - -```bash -grep -n "" /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -``` - -If missing, add `#include ` near the top with the other STL includes. - -- [ ] **Step 2: Replace `Transform` with the real implementation** - -Replace the stub `Transform` body with: - -```cpp - void Transform(std::unique_ptr frame) override { - if (!frame) return; - const uint32_t ssrc = frame->GetSsrc(); - - // Path A: kDrained — pass through. - // Path B: kBuffering — buffer. - // Path C: new SSRC — insert + post discovery + buffer. - // Path D: at the SSRC cap — drop silently. - rtc::scoped_refptr liveSink; - bool notifyDiscovery = false; - std::unique_ptr liveFrame; - { - webrtc::MutexLock lock(&_mu); - evictExpired_n(); - - auto it = _entries.find(ssrc); - if (it == _entries.end()) { - if (_entries.size() >= kMaxConcurrentBufferedSsrcs) { - // Path D: drop without notify, no allocation. - return; - } - Entry entry; - entry.firstFrameTimeMs = rtc::TimeMillis(); - entry.buffer.push_back(std::move(frame)); - _entries.emplace(ssrc, std::move(entry)); - notifyDiscovery = true; - } else if (it->second.state == SsrcState::kBuffering) { - if (it->second.buffer.size() >= kMaxBufferedFramesPerSsrc) { - it->second.buffer.pop_front(); - } - it->second.buffer.push_back(std::move(frame)); - } else { - // kDrained: pick the live sink and emit outside the lock. - auto sinkIt = _perSsrcSinks.find(ssrc); - if (sinkIt != _perSsrcSinks.end()) { - liveSink = sinkIt->second; - } else { - liveSink = _broadcastSink; - } - liveFrame = std::move(frame); - } - } - - if (liveSink && liveFrame) { - liveSink->OnTransformedFrame(std::move(liveFrame)); - } - if (notifyDiscovery && _onNewSsrc) { - _onNewSsrc(ssrc); - } - } -``` - -- [ ] **Step 3: Replace `releaseSsrc` with the real implementation** - -Replace the stub `releaseSsrc` body with: - -```cpp - void releaseSsrc(uint32_t ssrc) { - std::deque> toFlush; - rtc::scoped_refptr sink; - { - webrtc::MutexLock lock(&_mu); - auto it = _entries.find(ssrc); - if (it == _entries.end() || it->second.state == SsrcState::kDrained) { - // Either the SSRC was unknown, or already drained. Mark drained - // so future frames take the live-passthrough path. - if (it == _entries.end()) { - Entry entry; - entry.state = SsrcState::kDrained; - entry.firstFrameTimeMs = rtc::TimeMillis(); - _entries.emplace(ssrc, std::move(entry)); - } - return; - } - it->second.state = SsrcState::kDrained; - toFlush = std::move(it->second.buffer); - - auto sinkIt = _perSsrcSinks.find(ssrc); - if (sinkIt != _perSsrcSinks.end()) { - sink = sinkIt->second; - } else { - sink = _broadcastSink; - } - } - - if (!sink) return; - while (!toFlush.empty()) { - auto f = std::move(toFlush.front()); - toFlush.pop_front(); - sink->OnTransformedFrame(std::move(f)); - } - } -``` - -- [ ] **Step 4: Implement `evictExpired_n`** - -Add this private method definition inside the class (e.g., after `releaseSsrc`): - -```cpp - void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu) { - const int64_t now = rtc::TimeMillis(); - for (auto& [ssrc, entry] : _entries) { - if (entry.state == SsrcState::kBuffering && - !entry.buffer.empty() && - (now - entry.firstFrameTimeMs) > kSsrcDiscoveryTimeoutMs) { - entry.buffer.clear(); - // Leave the entry in kBuffering with empty buffer so we don't - // re-notify discovery. If the application ever recovers - // (renegotiation completes belatedly), releaseSsrc will still - // mark kDrained and live frames will flow through. - } - } - } -``` - -- [ ] **Step 5: Add `rtc::TimeMillis` include if missing** - -```bash -grep -n "rtc::TimeMillis\b\|rtc_base/time_utils.h" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp | head -5 -``` - -If only the call shows up but not the header, add: - -```cpp -#include "rtc_base/time_utils.h" -``` - -near the other rtc_base includes. - -- [ ] **Step 6: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 7: Run T1 — still expected to fail (transformer still not installed)** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`, `EXIT=1`. Code is built but unused. - -- [ ] **Step 8: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: implement GRAudioFrameTransformer state machine - -Per-SSRC buffer / drain / passthrough. releaseSsrc transitions -kBuffering→kDrained atomically (mark under lock, drain outside lock) -to avoid races. evictExpired_n bounds buffer lifetime to 1s. - -Caps: kMaxConcurrentBufferedSsrcs=64, kMaxBufferedFramesPerSsrc=60. -Worst-case memory ≈ 308 KB. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 5: Wire the transformer into `start()` (catch-all only) and observe discovery callback fires - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` - -**Why staged:** Install on the catch-all and prove `Transform` fires (via the `_onNewSsrc` callback). Discovery doesn't yet drive a renegotiation — that's the next task. After this task, T1 should still FAIL but with the discovery callback observably firing. - -- [ ] **Step 1: Add the member field** - -Locate the `Audio.` block in the private member section (around line 1556 — `_outgoingAudioTrack`, `_outgoingAudioTransceiver`). Add immediately after: - -```cpp - // Per-receiver audio frame transformer (catch-all + every recvonly). - rtc::scoped_refptr _audioFrameTransformer; -``` - -- [ ] **Step 2: Add a forward declaration for `handleDiscoveredAudioSsrc` (function defined in Task 6)** - -Inside the class, near the other private method declarations / definitions for `handleActiveAudioSsrcs`-replacement work — for now, declare a stub: - -```cpp - void handleDiscoveredAudioSsrc(uint32_t ssrc) { - // To be implemented in Task 6. - RTC_LOG(LS_INFO) << "GroupRef: discovered audio SSRC " << ssrc; - } -``` - -Place this near the existing media-thread methods (e.g., near `wireRemoteAudioLevelSinks`). - -- [ ] **Step 3: Construct + install the transformer in `start()`** - -Locate the block after `_outgoingAudioTransceiver` is created (around line 386, immediately after the `params.encodings[0].max_bitrate_bps = ...; _outgoingAudioTransceiver->sender()->SetParameters(params);` block). Insert before `_outgoingAudioTrack->set_enabled(false); // Muted by default.`: - -```cpp - // Install the audio frame transformer on mid=0's receiver. The - // receive side of this sendrecv transceiver is PeerConnection's - // catch-all for unsignaled SSRCs, so the transformer captures - // every previously-unseen remote SSRC. The same instance is - // attached to each recvonly receiver in renegotiate() so that - // live audio passes through the transformer consistently - // (and so the e2e PR has a single attachment point). - _audioFrameTransformer = rtc::make_ref_counted( - /*onNewSsrc=*/[weak, threads = _threads](uint32_t ssrc) { - threads->getMediaThread()->PostTask([weak, ssrc]() { - if (auto strong = weak.lock()) { - strong->handleDiscoveredAudioSsrc(ssrc); - } - }); - }, - /*decrypt=*/nullptr, // wired by the e2e PR - /*mediaThread=*/_threads->getMediaThread().get()); - _outgoingAudioTransceiver->receiver() - ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); -``` - -Note: `weak` is the `weak_ptr` already in scope earlier in `start()`. Verify by re-reading the start of `start()`: - -```bash -sed -n '173,195p' /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -``` - -If `weak` is not in scope at the insertion point, declare locally: - -```cpp - const auto weak = std::weak_ptr(shared_from_this()); -``` - -- [ ] **Step 4: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 5: Run T1 with verbose output — confirm discovery callback fires** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 2>&1 \ - | grep "discovered audio SSRC" | head -10 -``` - -Expected: at least one `GroupRef: discovered audio SSRC ` line per remote peer per participant. (Logs go through `LogSinkImpl` which writes to a per-participant log file then deletes; if the grep returns nothing, briefly add `fprintf(stderr, "...")` mirroring the RTC_LOG to verify, then revert before commit.) - -- [ ] **Step 6: Run T1 (full) — still expected to FAIL** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: FAILED`. The handler is a logging stub; no recvonly transceiver is added; audio is dropped (no `OnTransformedFrame` call yet because the entry is `kBuffering` indefinitely). - -- [ ] **Step 7: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: install GRAudioFrameTransformer on mid=0 - -The transformer is now in the depacketizer path of mid=0's receiver -(PeerConnection's catch-all for unsignaled audio). Every previously- -unseen SSRC fires the discovery callback, which currently just logs. -The next commit drives renegotiation and flushes buffered audio. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 6: Implement `handleDiscoveredAudioSsrc` + `scheduleDiscoveryRenegotiation` and call `releaseSsrc` from `onRenegotiationComplete` - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` - -**Why staged:** This is the GREEN step — the test harness should pass for the first time after this task. Wiring discovery → renegotiation → release closes the loop, but until we also install the transformer on each recvonly receiver (Task 7), live frames after the flush continue to take the catch-all path. That's tolerable today (catch-all is the same instance) but Task 7 makes it explicit and future-proof. - -- [ ] **Step 1: Add `_discoveryRenegotiationScheduled` member** - -In the private member section, near `_isRenegotiating`: - -```cpp - // Discovery-renegotiation debounce. - bool _discoveryRenegotiationScheduled = false; -``` - -- [ ] **Step 2: Replace the `handleDiscoveredAudioSsrc` stub with the real implementation** - -```cpp - // Single entry point for adding a remote audio SSRC. Runs on the - // media thread (posted to from the worker-thread frame transformer - // callback). - void handleDiscoveredAudioSsrc(uint32_t ssrc) { - if (ssrc == 0) return; - if (ssrc == _outgoingSsrc) return; - if (_remoteSsrcs.count(ssrc) > 0) return; - - std::string mid = std::to_string(_nextMid++); - RemoteSsrcInfo info; - info.mid = mid; - _remoteSsrcs.emplace(ssrc, std::move(info)); - - if (_requestMediaChannelDescriptions) { - _requestMediaChannelDescriptions({ssrc}, - [](std::vector&&) {}); - } - scheduleDiscoveryRenegotiation(); - - RTC_LOG(LS_INFO) << "GroupRef: queued discovered audio SSRC " << ssrc - << " (mid=" << mid << ")"; - } -``` - -- [ ] **Step 3: Add `scheduleDiscoveryRenegotiation`** - -Place near the existing `renegotiate()` method: - -```cpp - static constexpr int kDiscoveryRenegotiationDelayMs = 250; - - void scheduleDiscoveryRenegotiation() { - if (_discoveryRenegotiationScheduled) return; - _discoveryRenegotiationScheduled = true; - - const auto weak = std::weak_ptr(shared_from_this()); - _threads->getMediaThread()->PostDelayedTask( - [weak]() { - auto strong = weak.lock(); - if (!strong) return; - strong->_discoveryRenegotiationScheduled = false; - strong->renegotiate(); - }, - webrtc::TimeDelta::Millis(kDiscoveryRenegotiationDelayMs)); - } -``` - -- [ ] **Step 4: Add the `releaseSsrc` loop in `onRenegotiationComplete`** - -Locate `onRenegotiationComplete()` (around line 1314). Insert after `wireRemoteAudioLevelSinks();` and before `_isRenegotiating = false;`: - -```cpp - if (_audioFrameTransformer) { - for (auto& [ssrc, info] : _remoteSsrcs) { - if (info.transceiver && info.transceiver->mid().has_value()) { - _audioFrameTransformer->releaseSsrc(ssrc); - } - } - } -``` - -- [ ] **Step 5: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -10 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 6: Run T1 — should now SUCCESS** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet -echo "EXIT=$?" -``` - -Expected: `Result: SUCCESS`, `Audio received: 3/3`, `EXIT=0`. - -If FAIL: re-read the spec's "Where flushed frames actually go" section. The most likely issue is that the per-SSRC sink callback registered when the unsignaled stream was created is no longer the one routing to the (now-promoted) audio receive stream's decoder. Add a one-shot debug print inside `releaseSsrc` to confirm `sink != nullptr` and `toFlush.size() > 0`. - -- [ ] **Step 7: Run T2-T5 — verify no regressions** - -Run each scenario from the "Test bench reference" block above (T1 through T5). All five must report `Result: SUCCESS` and `EXIT=0`. - -If T3-T5 (muted-peer scenarios) pass: the muted-peer invariant from the previous fix still holds, so the per-receiver audio-level sinks are correctly wired and reading PCM from the post-promotion stream. - -- [ ] **Step 8: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: drive discovery renegotiation, flush buffer - -- handleDiscoveredAudioSsrc inserts into _remoteSsrcs, fires - _requestMediaChannelDescriptions per SSRC, schedules a debounced - renegotiation. -- scheduleDiscoveryRenegotiation coalesces bursts: at most one - renegotiation per 250ms, regardless of how many SSRCs land in - the window. -- onRenegotiationComplete iterates _remoteSsrcs and calls - releaseSsrc(ssrc) for every entry whose transceiver now has a - mid, draining the per-SSRC FIFO into OnTransformedFrame so the - buffered audio plays without a startup gap. - -T1-T5 all pass. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 7: Install `_audioFrameTransformer` on every recvonly transceiver as it's added - -**Files:** -- Modify: `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` - -**Why now (not in Task 6):** Today the catch-all transformer covers live audio for promoted streams (it's already on the stream from the unsignaled-stream creation path). Explicitly installing it on each recvonly receiver removes our reliance on internal WebRTC stream-promotion behavior carrying the transformer along, and matches the exact attachment pattern the e2e PR will use. - -- [ ] **Step 1: Find the AddTransceiver call in `renegotiate()`** - -```bash -grep -n "AddTransceiver(cricket::MEDIA_TYPE_AUDIO" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -``` - -Expected: a single hit inside `renegotiate()` (around line 1142). - -- [ ] **Step 2: Attach the transformer right after the transceiver is created** - -Modify the block that handles the success branch of `AddTransceiver`. Original: - -```cpp - auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); - if (result.ok()) { - info.transceiver = result.value(); - RTC_LOG(LS_INFO) << "GroupRef: Added recvonly transceiver for SSRC " << ssrc; - } -``` - -Replace with: - -```cpp - auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); - if (result.ok()) { - info.transceiver = result.value(); - if (_audioFrameTransformer) { - info.transceiver->receiver() - ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); - } - RTC_LOG(LS_INFO) << "GroupRef: Added recvonly transceiver for SSRC " << ssrc; - } -``` - -- [ ] **Step 3: Build** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 -``` - -Expected: `INFO: Build completed successfully`. - -- [ ] **Step 4: Run T1-T5 — must all SUCCESS** - -```bash -for cfg in \ - "0 3 --duration 6" \ - "2 2 --duration 6" \ - "0 3 --mute-participants 0 --duration 6" \ - "1 2 --mute-participants 0 --duration 6" \ - "2 1 --mute-participants 2 --duration 6"; do - /Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants $cfg --quiet 2>&1 | tail -3 - echo "EXIT=$?"; echo "---" -done -``` - -Each block must end with `Result: SUCCESS` and `EXIT=0`. - -- [ ] **Step 5: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp -git commit -m "$(cat <<'EOF' -GroupInstanceReferenceImpl: attach frame transformer to each recvonly receiver - -Live audio for a promoted SSRC was already flowing through our -transformer (carried over from the unsignaled stream creation -path), but we depended on internal WebRTC stream-promotion -behavior to make that work. Explicit per-receiver attachment -removes that dependency and matches what the e2e PR will need. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 8: Documentation refresh - -**Files:** -- Modify: `submodules/TgVoipWebrtc/CLAUDE.md` -- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` -- Modify: `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md` - -- [ ] **Step 1: Update `submodules/TgVoipWebrtc/CLAUDE.md` — ReferenceImpl section** - -Find the "GroupInstanceReferenceImpl" section. Update the "Dynamic Participant Handling → Audio" sub-bullet: - -Before (or similar): -> 1. SFU sends `{"colibriClass":"ActiveAudioSsrcs","ssrcs":[54321,98765]}` over data channel -> 2. Client diffs against known SSRCs -> 3. New SSRCs: add recvonly audio transceiver → renegotiate (new offer + constructed answer mirroring offer mids) -> 4. Removed SSRCs: clean up from tracking map - -After: -> 1. `GRAudioFrameTransformer` (installed on mid=0's receiver and on every recvonly audio receiver) sees a frame with an unknown SSRC at the depacketizer→decoder boundary, buffers it, and notifies the media thread. -> 2. `handleDiscoveredAudioSsrc` inserts the SSRC into `_remoteSsrcs`, fires `_requestMediaChannelDescriptions({ssrc}, ...)` (matches CustomImpl's contract), and schedules a 250 ms-debounced renegotiation. -> 3. Renegotiation adds a recvonly transceiver bound to the new SSRC; `buildRemoteAnswer` includes the SSRC on the new m-line; `WebRtcVoiceReceiveChannel::AddRecvStream` promotes the existing unsignaled stream in place. -> 4. `onRenegotiationComplete` calls `_audioFrameTransformer->releaseSsrc(ssrc)`, which drains the buffered FIFO via `OnTransformedFrame` so the user hears the participant's first ~250–500 ms of audio without a gap. -> 5. Subsequent live frames for the SSRC pass through the transformer's `kDrained` branch directly to the decoder. -> -> The `colibriClass=ActiveAudioSsrcs` data-channel mechanism (test-SFU only) was removed; the tap is the single discovery path. Removed-SSRC handling is the same as CustomImpl's: stale recvonly transceivers stay in the SDP indefinitely; participant departures are tracked at the application layer (MTProto). - -- [ ] **Step 2: Update `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — group flow** - -Find the "Architecture (Group)" section. Remove the bullet about `ActiveAudioSsrcs` ("SSRC discovery: SFU broadcasts `ActiveAudioSsrcs` and `ActiveVideoSsrcs` ...") and replace with: - -> SSRC discovery: video SSRCs are broadcast via `ActiveVideoSsrcs` (used by the test app's `dataChannelMessageReceived` callback to call `setRequestedVideoChannels`). Audio SSRCs are discovered by `GroupInstanceReferenceImpl`'s per-receiver `GRAudioFrameTransformer` directly from incoming RTP — same shape CustomImpl uses (`receiveUnknownSsrcPacket` → `_requestMediaChannelDescriptions`). - -- [ ] **Step 3: Update `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md`** - -In the bullet that lists `sfu.go`'s responsibilities, remove the phrase `'ActiveAudioSsrcs'/`ActiveVideoSsrcs' broadcasting` and replace with `ActiveVideoSsrcs broadcasting` (audio is no longer broadcast). - -- [ ] **Step 4: Verify builds still work after the doc changes (defensive)** - -```bash -/Users/isaac/build/telegram/telegram-ios/build-input/bazel-8.4.2-darwin-arm64 \ - build //submodules/TgVoipWebrtc/tgcalls/tools/cli:tgcalls_cli 2>&1 | tail -3 -``` - -Expected: `INFO: Build completed successfully` (no source files touched, but a no-op build verifies the docs don't accidentally break a generated file). - -- [ ] **Step 5: Commit** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git add submodules/TgVoipWebrtc/CLAUDE.md \ - submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md \ - submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/CLAUDE.md -git commit -m "$(cat <<'EOF' -docs: ReferenceImpl SSRC discovery via per-receiver frame transformer - -Document the new GRAudioFrameTransformer-based audio SSRC discovery -flow in both the tgcalls library overview and the test-bench notes. -Drop ActiveAudioSsrcs references — the message no longer exists. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -### Task 9: Final regression sweep - -**Files:** none changed. - -- [ ] **Step 1: Run the full sweep** - -```bash -echo "=== T1: All-Reference no mute ===" -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --duration 6 --quiet | tail -3 -echo "EXIT=$?" - -echo "=== T2: Mixed (2C+2R) no mute ===" -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 2 --reference-participants 2 --duration 6 --quiet | tail -3 -echo "EXIT=$?" - -echo "=== T3: All-Reference, P0 muted ===" -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 --quiet | tail -3 -echo "EXIT=$?" - -echo "=== T4: Mixed (1C+2R), custom muted ===" -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 1 --reference-participants 2 --mute-participants 0 --duration 6 --quiet | tail -3 -echo "EXIT=$?" - -echo "=== T5: Mixed (2C+1R), reference muted ===" -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 2 --reference-participants 1 --mute-participants 2 --duration 6 --quiet | tail -3 -echo "EXIT=$?" -``` - -Expected: every block ends with `Result: SUCCESS` and `EXIT=0`. - -- [ ] **Step 2: Confirm muted-peer level invariant explicitly (T3 verbose)** - -```bash -/Users/isaac/build/telegram/telegram-ios/bazel-bin/submodules/TgVoipWebrtc/tgcalls/tools/cli/tgcalls_cli \ - --mode group --participants 0 --reference-participants 3 --mute-participants 0 --duration 6 2>&1 \ - | grep -E "Validate" | head -5 -``` - -Expected: -``` -Validate: OK: P1 (ref) reported muted P0 (ssrc=...) at max level 0.000 -Validate: OK: P2 (ref) reported muted P0 (ssrc=...) at max level 0.000 -``` - -- [ ] **Step 3: Confirm no leftover `ActiveAudioSsrcs` references** - -```bash -grep -rn "ActiveAudioSsrcs\|handleActiveAudioSsrcs\|broadcastActiveSSRCs\|buildActiveSSRCsMessage" \ - /Users/isaac/build/telegram/telegram-ios/submodules/TgVoipWebrtc/ 2>/dev/null | grep -v ".log\|.o\|index/\|bazel-" -``` - -Expected: empty (or only matches inside `.git/`). - -- [ ] **Step 4: Confirm git log structure** - -```bash -cd /Users/isaac/build/telegram/telegram-ios -git log --oneline -10 -``` - -Expected to see 8 new commits from this PR (Tasks 1, 2, 3, 4, 5, 6, 7, 8) since the previous baseline. - -- [ ] **Step 5: This task has no commit** - -The sweep is verification-only. - ---- - -## Out of scope (do NOT touch in this PR) - -- `descriptor.e2eEncryptDecrypt` wiring. The seam (`DecryptHook` constructor parameter on `GRAudioFrameTransformer`) is in place; the actual decrypt callback is the next PR. -- Outgoing-audio SSRC>int31 masking. Separate fix. -- Push-style discovery via a new `GroupInstanceInterface::addIncomingAudioSsrcs(...)` method. -- Removed-SSRC cleanup (recvonly transceivers stay in the SDP indefinitely — same as CustomImpl). -- iOS-side code changes — the existing `_requestMediaChannelDescriptions` callback already serves the discovery path. - ---- - -## Self-review notes - -- **Spec coverage:** Every section of the spec has at least one task. Frame transformer (Tasks 3–4), every-receiver install (Tasks 5, 7), discovery + debounce + release (Task 6), `ActiveAudioSsrcs` removal (Tasks 1–2), docs (Task 8), regression sweep (Task 9). -- **Type / name consistency:** `GRAudioFrameTransformer` (not `GRSsrcTapTransformer`) used everywhere. Member field `_audioFrameTransformer` everywhere. Constants `kSsrcDiscoveryTimeoutMs`, `kMaxBufferedFramesPerSsrc`, `kMaxConcurrentBufferedSsrcs`, `kDiscoveryRenegotiationDelayMs` defined once and referenced once. -- **Placeholder scan:** No "TBD"/"TODO"/"similar to N" — every code-touching step has the actual code. -- **Test verification:** The test bench T1–T5 are referenced by exact CLI invocations, with the muted-peer invariant called out as the strongest signal for routing regressions. Task 1's intentional regression is explicitly framed as TDD-red so the engineer doesn't think they broke something. diff --git a/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md b/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md deleted file mode 100644 index c1a523b5a6..0000000000 --- a/docs/superpowers/plans/2026-05-01-rich-bubble-instant-page-link-handling.md +++ /dev/null @@ -1,655 +0,0 @@ -# Rich-bubble instant-page link handling Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire URL tap routing and link-highlight feedback into `ChatMessageRichDataBubbleContentNode`, plus stubbed handlers for intra-page anchor scrolling. - -**Architecture:** All changes land in a single Swift file (`ChatMessageRichDataBubbleContentNode.swift`) plus its BUILD file. Tap detection mirrors `InstantPageControllerNode.textItemAtLocation` / `urlForTapLocation` scoped to the bubble's already-built `currentPageLayout`. URL hits return a standard `ChatMessageBubbleContentTapAction(.url(...), rects:, activate:)` which the bubble framework routes through `controllerInteraction.openUrl`. Highlight feedback uses a `LinkHighlightingNode` overlay inside `containerNode`, driven by the `activate` `Promise` exactly like the chat text bubble. Same-page-anchor URLs short-circuit into a no-op `scrollToAnchor(_:)` placeholder for later implementation. - -**Tech Stack:** Swift, AsyncDisplayKit, Bazel (`build-system/Make/Make.py`), modules `Display`, `InstantPageUI`, `ChatControllerInteraction`, `ChatMessageBubbleContentNode`, `TelegramCore`, `SwiftSignalKit`. - -**Reference spec:** `docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md` - -**Project context:** No automated tests exist (per `CLAUDE.md`). Per-task verification is "build green" using: - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -Final manual smoke test runs the app and exercises the feature in the simulator. - ---- - -## File map - -- **Modified:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — adds tap detection helpers, link-highlight state and overlay management, real `openPeer`/`openUrl` item callbacks, anchor stub, and a populated `tapActionAtPoint`. -- **Modified:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — adds `//submodules/TelegramUI/Components/ChatControllerInteraction` to `deps`. - -No other files change. - ---- - -### Task 1: Add `ChatControllerInteraction` BUILD dep + import - -The rich-bubble currently does not have access to `ChatControllerInteraction` as an importable module. Sibling modules (e.g. `ChatMessageWebpageBubbleContentNode`) list it explicitly in BUILD deps and import it. We follow the same pattern. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift:1-12` - -- [ ] **Step 1: Add the dep to BUILD** - -In `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD`, add the new dep line at the end of the existing `deps` list (alphabetical order is not enforced in this repo's BUILD files; place it after `ChatMessageItemCommon`): - -Replace: -``` - deps = [ - "//submodules/AsyncDisplayKit", - "//submodules/Display", - "//submodules/TelegramCore", - "//submodules/Postbox", - "//submodules/SSignalKit/SwiftSignalKit", - "//submodules/AccountContext", - "//submodules/InstantPageUI", - "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", - "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", - "//submodules/TelegramUIPreferences", - ], -``` - -With: -``` - deps = [ - "//submodules/AsyncDisplayKit", - "//submodules/Display", - "//submodules/TelegramCore", - "//submodules/Postbox", - "//submodules/SSignalKit/SwiftSignalKit", - "//submodules/AccountContext", - "//submodules/InstantPageUI", - "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", - "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", - "//submodules/TelegramUI/Components/ChatControllerInteraction", - "//submodules/TelegramUIPreferences", - ], -``` - -- [ ] **Step 2: Add the import** - -At the top of `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`, after `import ChatMessageItemCommon` add `import ChatControllerInteraction`: - -Replace: -```swift -import ChatMessageBubbleContentNode -import ChatMessageItemCommon -import InstantPageUI -import TelegramUIPreferences -``` - -With: -```swift -import ChatMessageBubbleContentNode -import ChatMessageItemCommon -import ChatControllerInteraction -import InstantPageUI -import TelegramUIPreferences -``` - -- [ ] **Step 3: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. The new import is unused; Swift does not warn on unused module imports, so `-warnings-as-errors` is fine. - -- [ ] **Step 4: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: add ChatControllerInteraction dep and import" -``` - ---- - -### Task 2: Add link-progress state, deinit cleanup, and helper stubs - -Before introducing tap-detection or item-callback logic, add the supporting plumbing: progress state, the empty `scrollToAnchor` placeholder, the URL-anchor splitter, and a `currentLoadedWebpage()` accessor. These are private helpers that future tasks will consume — Swift does not warn on unused private functions, so the build stays green. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Add the new ivars** - -Locate the existing block of stored properties around lines 18–25: - -```swift - private let containerNode: ContainerNode - private var currentLayoutTiles: [InstantPageTile] = [] - private var visibleTiles: [Int: InstantPageTileNode] = [:] - private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] - private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? - private var distanceThresholdGroupCount: [Int: Int] = [:] - private var currentLayoutItemsWithNodes: [InstantPageItem] = [] - private var currentExpandedDetails: [Int : Bool]? -``` - -Append the three highlight-related fields immediately after `currentExpandedDetails`: - -```swift - private let containerNode: ContainerNode - private var currentLayoutTiles: [InstantPageTile] = [] - private var visibleTiles: [Int: InstantPageTileNode] = [:] - private var visibleItemsWithNodes: [Int: InstantPageNode] = [:] - private var currentPageLayout: (boundingWidth: CGFloat, layout: InstantPageLayout)? - private var distanceThresholdGroupCount: [Int: Int] = [:] - private var currentLayoutItemsWithNodes: [InstantPageItem] = [] - private var currentExpandedDetails: [Int : Bool]? - private var linkProgressDisposable: Disposable? - private var linkProgressRects: [CGRect]? - private var linkHighlightingNode: LinkHighlightingNode? -``` - -- [ ] **Step 2: Update `deinit` to dispose the link-progress signal** - -Locate the existing empty `deinit` (around line 48): - -```swift - deinit { - } -``` - -Replace with: - -```swift - deinit { - self.linkProgressDisposable?.dispose() - } -``` - -- [ ] **Step 3: Add helper methods at the end of the class** - -Locate the closing brace of the class (the last `}` in the file). Immediately before it, add the four helpers (anchor split, current-loaded-webpage accessor, anchor-scroll stub, and a small TODO note): - -```swift - private func splitAnchor(_ url: String) -> (base: String, anchor: String?) { - if let anchorRange = url.range(of: "#") { - let anchor = String(url[anchorRange.upperBound...]).removingPercentEncoding - let base = String(url[.. TelegramMediaWebpageLoadedContent? { - guard let item = self.item else { - return nil - } - guard let webpage = item.message.media.first(where: { $0 is TelegramMediaWebpage }) as? TelegramMediaWebpage else { - return nil - } - if case let .Loaded(content) = webpage.content { - return content - } - return nil - } - - private func scrollToAnchor(_ anchor: String) { - // TODO: implement intra-page anchor scrolling - let _ = anchor - } -``` - -The `let _ = anchor` line silences any "unused parameter" lint while keeping the parameter name visible. (Required because `-warnings-as-errors` is enabled on this module.) - -- [ ] **Step 4: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. Private functions (even unused) and disposable ivars compile cleanly under `-warnings-as-errors`. - -- [ ] **Step 5: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: add link-progress state and anchor-scroll stub" -``` - ---- - -### Task 3: Wire item-level `openPeer` and `openUrl` callbacks - -Replace the stubbed item-callback closures inside the `item.node(...)` invocation. Item nodes themselves emit URL/peer taps via their callback parameters; we route these to `controllerInteraction.openUrl` / `controllerInteraction.openPeer`. `openMedia`, `longPressMedia`, `activatePinchPreview`, `pinchPreviewFinished`, `updateWebEmbedHeight`, and `updateDetailsExpanded` remain explicit no-ops (per spec — out of scope for this change). - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (the `item.node(...)` call inside `updateVisibleItems`, currently at lines ~233–278) - -- [ ] **Step 1: Replace the `item.node(...)` callback block** - -Locate the existing call (the entire block from `if let newNode = item.node(context: ...` through the matching `currentExpandedDetails: ..., getPreloadedResource: { _ in return nil }) {` line). Replace ONLY the trailing-closure callback parameters — keep `context:`, `strings:`, `nameDisplayOrder:`, `theme:`, `sourceLocation:`, `currentExpandedDetails:`, and `getPreloadedResource:` intact. - -Replace the existing block: - -```swift - if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { [weak self] media in - let _ = self - //self?.openMedia(media) - }, longPressMedia: { [weak self] media in - //self?.longPressMedia(media) - let _ = self - }, activatePinchPreview: { [weak self] sourceNode in - /*guard let strongSelf = self, let controller = strongSelf.controller else { - return - } - let pinchController = makePinchController(sourceNode: sourceNode, getContentAreaInScreenSpace: { - guard let strongSelf = self else { - return CGRect() - } - - let localRect = CGRect(origin: CGPoint(x: 0.0, y: strongSelf.navigationBar.frame.maxY), size: CGSize(width: strongSelf.bounds.width, height: strongSelf.bounds.height - strongSelf.navigationBar.frame.maxY)) - return strongSelf.view.convert(localRect, to: nil) - }) - controller.window?.presentInGlobalOverlay(pinchController)*/ - let _ = self - }, pinchPreviewFinished: { [weak self] itemNode in - /*guard let strongSelf = self else { - return - } - for (_, listItemNode) in strongSelf.visibleItemsWithNodes { - if let listItemNode = listItemNode as? InstantPagePeerReferenceNode { - if listItemNode.frame.intersects(itemNode.frame) && listItemNode.frame.maxY <= itemNode.frame.maxY + 2.0 { - listItemNode.layer.animateAlpha(from: 0.0, to: listItemNode.alpha, duration: 0.25) - break - } - } - }*/ - let _ = self - }, openPeer: { [weak self] peerId in - let _ = self - //self?.openPeer(peerId) - }, openUrl: { [weak self] url in - let _ = self - //self?.openUrl(url) - }, updateWebEmbedHeight: { [weak self] height in - let _ = self - //self?.updateWebEmbedHeight(embedIndex, height) - }, updateDetailsExpanded: { [weak self] expanded in - let _ = self - //self?.updateDetailsExpanded(detailsIndex, expanded) - }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { -``` - -With: - -```swift - if let newNode = item.node(context: messageItem.context, strings: messageItem.presentationData.strings, nameDisplayOrder: messageItem.presentationData.nameDisplayOrder, theme: pageTheme, sourceLocation: sourceLocation, openMedia: { _ in - // TODO: media handling — out of scope for link wiring - }, longPressMedia: { _ in - // TODO - }, activatePinchPreview: { _ in - // TODO - }, pinchPreviewFinished: { _ in - // TODO - }, openPeer: { [weak self] peer in - guard let self, let item = self.item else { - return - } - item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) - }, openUrl: { [weak self] urlItem in - guard let self, let item = self.item else { - return - } - let split = self.splitAnchor(urlItem.url) - if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { - self.scrollToAnchor(anchor) - return - } - item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( - url: urlItem.url, - concealed: false, - message: item.message, - allowInlineWebpageResolution: urlItem.webpageId != nil - )) - }, updateWebEmbedHeight: { _ in - // TODO - }, updateDetailsExpanded: { _ in - // TODO - }, currentExpandedDetails: self.currentExpandedDetails, getPreloadedResource: { _ in return nil }) { -``` - -Notes on this block: -- `openPeer` parameter is `(EnginePeer) -> Void` (verified against `InstantPageItem.swift:18`) — name it `peer`, not `peerId`. -- `openUrl` parameter is `(InstantPageUrlItem) -> Void` — name it `urlItem` to disambiguate from the outer URL string. -- The same-page-anchor short-circuit calls the empty `scrollToAnchor` stub so future implementation is single-point. -- `urlItem.webpageId != nil` is mapped to `allowInlineWebpageResolution` because the IV's `webpageId` hint signals "this URL was authored as a referenced webpage" — the same intent as the chat flag. -- `concealed: false` for item-emitted URLs (item nodes only emit clearly-typed link items, not free-form anchor-text mismatches). The text-tap path in Task 4 uses `concealed: true` per spec. - -- [ ] **Step 2: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. If a closure-parameter type mismatch is reported, re-check `InstantPageItem.swift:18` for the canonical signature. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: route item-level openPeer/openUrl to controllerInteraction" -``` - ---- - -### Task 4: Implement URL tap detection, highlight feedback, and `tapActionAtPoint` - -Add the tap-detection helpers, the rect-translation helper, the `makeActivate` factory, and the `updateLinkProgressState` view-state applier. Then rewrite `tapActionAtPoint` to use them. This is the largest task; it must land atomically because the helpers and the rewritten `tapActionAtPoint` reference each other. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Replace `tapActionAtPoint` and add tap-detection helpers** - -Locate the existing `tapActionAtPoint` (around lines 403–442 with its commented-out `makeActivate` block) and replace the entire method. Then add the new helpers immediately afterward (so they live alongside the related logic). - -Replace the existing method body: - -```swift - override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { - if case .tap = gesture { - } else { - if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { - return ChatMessageBubbleContentTapAction(content: .none) - } - } - - /*func makeActivate(_ urlRange: NSRange?) -> (() -> Promise?)? { - return { [weak self] in - guard let self else { - return nil - } - - let promise = Promise() - - self.linkProgressDisposable?.dispose() - - if self.linkProgressRange != nil { - self.linkProgressRange = nil - self.updateLinkProgressState() - } - - self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in - guard let self else { - return - } - let updatedRange: NSRange? = value ? urlRange : nil - if self.linkProgressRange != updatedRange { - self.linkProgressRange = updatedRange - self.updateLinkProgressState() - } - }) - - return promise - } - }*/ - - return ChatMessageBubbleContentTapAction(content: .none) - } -``` - -With: - -```swift - override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { - if case .tap = gesture { - } else { - if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { - return ChatMessageBubbleContentTapAction(content: .none) - } - } - - guard let urlHit = self.urlForTapLocation(point) else { - return ChatMessageBubbleContentTapAction(content: .none) - } - - let split = self.splitAnchor(urlHit.urlItem.url) - if let webpage = self.currentLoadedWebpage(), webpage.url == split.base, let anchor = split.anchor { - return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in - self?.scrollToAnchor(anchor) - })) - } - - // Default to concealed=true: InstantPageTextItem does not expose a clean - // "attribute substring with displayed range" API, so we cannot compare - // displayed text to the resolved URL the way the chat text bubble does. - // The chat URL handler will show a confirmation when concealed is true - // and the visible text differs from the destination — safer default. - let concealed = true - let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) - let rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - return ChatMessageBubbleContentTapAction( - content: .url(url), - rects: rects, - activate: self.makeActivate(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint) - ) - } - - private func textItemAtLocation(_ location: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? { - guard let layout = self.currentPageLayout?.layout else { - return nil - } - // Translate from bubble-content-node coords to container-/layout-local coords. - let layoutLocation = location.offsetBy(dx: -1.0, dy: -1.0) - for item in layout.items { - let itemFrame = item.frame - if itemFrame.contains(layoutLocation) { - if let item = item as? InstantPageTextItem, item.selectable { - return (item, CGPoint(x: itemFrame.minX - item.frame.minX, y: itemFrame.minY - item.frame.minY)) - } else if let item = item as? InstantPageScrollableItem { - let contentOffset = CGPoint.zero - if let (textItem, parentOffset) = item.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX + contentOffset.x, dy: -itemFrame.minY)) { - return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x - contentOffset.x, dy: parentOffset.y)) - } - } else if let item = item as? InstantPageDetailsItem { - for (_, itemNode) in self.visibleItemsWithNodes { - if let itemNode = itemNode as? InstantPageDetailsNode, itemNode.item === item { - if let (textItem, parentOffset) = itemNode.textItemAtLocation(layoutLocation.offsetBy(dx: -itemFrame.minX, dy: -itemFrame.minY)) { - return (textItem, itemFrame.origin.offsetBy(dx: parentOffset.x, dy: parentOffset.y)) - } - } - } - } - } - } - return nil - } - - private func urlForTapLocation(_ point: CGPoint) -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, parentOffset: CGPoint, localPoint: CGPoint)? { - guard let (item, parentOffset) = self.textItemAtLocation(point) else { - return nil - } - // Translate bubble-content-node point → text-item-local point. - // (bubble-coords → layout-coords) is `- (1, 1)`; (layout → item-local) is `- item.frame.origin - parentOffset`. - let layoutPoint = point.offsetBy(dx: -1.0, dy: -1.0) - let localPoint = layoutPoint.offsetBy(dx: -item.frame.minX - parentOffset.x, dy: -item.frame.minY - parentOffset.y) - guard let urlItem = item.urlAttribute(at: localPoint) else { - return nil - } - return (item, urlItem, parentOffset, localPoint) - } - - private func computeHighlightRects(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> [CGRect] { - // Text item returns rects in its local coords; translate back into containerNode-local coords. - // containerNode is offset by (1, 1) from the bubble-content-node, but the highlight overlay lives - // *inside* containerNode, so we use layout-coords (= containerNode-local) for the rects. - let originX = item.frame.minX + parentOffset.x - let originY = item.frame.minY + parentOffset.y - return item.linkSelectionRects(at: localPoint).map { rect in - rect.offsetBy(dx: originX, dy: originY) - } - } - - private func makeActivate(item: InstantPageTextItem, parentOffset: CGPoint, localPoint: CGPoint) -> (() -> Promise?)? { - return { [weak self, weak item] in - guard let self else { - return nil - } - let promise = Promise() - self.linkProgressDisposable?.dispose() - if self.linkProgressRects != nil { - self.linkProgressRects = nil - self.updateLinkProgressState() - } - self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in - guard let self else { - return - } - let updated: [CGRect]? - if value, let item { - updated = self.computeHighlightRects(item: item, parentOffset: parentOffset, localPoint: localPoint) - } else { - updated = nil - } - let changed: Bool - if let lhs = self.linkProgressRects, let rhs = updated { - changed = lhs != rhs - } else { - changed = (self.linkProgressRects == nil) != (updated == nil) - } - if changed { - self.linkProgressRects = updated - self.updateLinkProgressState() - } - }) - return promise - } - } - - private func updateLinkProgressState() { - guard let messageItem = self.item else { - return - } - if let rects = self.linkProgressRects, !rects.isEmpty { - let highlightingNode: LinkHighlightingNode - if let current = self.linkHighlightingNode { - highlightingNode = current - } else { - let color: UIColor = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) - ? messageItem.presentationData.theme.theme.chat.message.incoming.linkHighlightColor - : messageItem.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor - highlightingNode = LinkHighlightingNode(color: color) - self.linkHighlightingNode = highlightingNode - self.containerNode.insertSubnode(highlightingNode, at: 0) - } - highlightingNode.frame = self.containerNode.bounds - highlightingNode.updateRects(rects) - } else if let highlightingNode = self.linkHighlightingNode { - self.linkHighlightingNode = nil - highlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak highlightingNode] _ in - highlightingNode?.removeFromSupernode() - }) - } - } -``` - -Notes: -- Coordinate translation: incoming `point` is bubble-content-node-local. The bubble's `containerNode.frame.origin` is `(1, 1)` (set in the `apply` closure around line 96). Subtracting `(1, 1)` once gives container-local = layout-local coordinates. The layout origin is `(0, 0)` inside the container. -- `InstantPageScrollableItem`'s realized node would be the source of truth for content-offset, but the rich-bubble does not surface scroll state and chat instant-pages rarely contain scrollable items. Pass `CGPoint.zero` for v1; if a chat preview ever uses a scrollable, the tap detection will be slightly off but URL-hit will still resolve when the scroll is at top. (Out of scope for this change; document as a follow-up if it becomes user-visible.) -- `[weak item]` in the activate closure avoids retaining the InstantPageTextItem across asynchronous URL resolution. If layout reflows during resolution and the original item is gone, the highlight simply falls back to clearing. -- The `changed` comparison for `[CGRect]?` is spelled out longhand because optional `Equatable` conformance for `[CGRect]?` requires the explicit nil-vs-non-nil discrimination to satisfy `-warnings-as-errors` cleanly. - -- [ ] **Step 2: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. - -Common likely errors and fixes: -- "Cannot find type `InstantPageTextItem` / `InstantPageScrollableItem` / `InstantPageDetailsItem` / `InstantPageDetailsNode` / `InstantPageUrlItem`" → all are public in `InstantPageUI`, already imported; rebuild without `--continueOnError` and re-check the exact error. -- "Cannot find `LinkHighlightingNode`" → it lives in `Display`, already imported. -- "`Promise` is ambiguous" → `Promise` is from `SwiftSignalKit`, already imported. -- A `linkHighlightColor` access that errors with optional unwrap → the type is non-optional `UIColor` on `MessageBubbleColorComponents`; this should compile cleanly. If the compiler reports a different type, drop back to `?? UIColor.clear` and document. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: implement URL tap detection and link-highlight feedback" -``` - ---- - -### Task 5: Manual smoke verification - -There are no automated tests for chat UI in this codebase. The final task is a hands-on smoke test in the simulator. - -**Files:** none modified. Verification only. - -- [ ] **Step 1: Launch the app in the simulator** - -The build command run in earlier tasks produces the app binary; launch via Xcode (open `Telegram-iOS.xcworkspace` if needed) or the Bazel-produced run target. Sign in to a real test account. - -- [ ] **Step 2: Find or send a message with an instant-view preview** - -In a chat, send a Telegram URL that has a rich instant-view preview (e.g. a Telegraph article URL: `https://telegra.ph/Test-page-12-31`, or any t.me link that produces an inline IV preview). The preview should render inside the chat bubble using the rich-data layout (multiple text/image tiles inside one bubble). - -- [ ] **Step 3: Tap a URL inside the inline IV preview** - -A standard URL link inside the preview text: -- Should highlight (semi-transparent rounded rectangle in the bubble's link-highlight color) for the duration of URL resolution. -- Should then route through the chat's URL handler — opening an in-app webview, an external browser, or a peer/chat depending on the URL. - -- [ ] **Step 4: Tap a same-page anchor (if available)** - -Some IV pages contain intra-page anchors like `#section-1`. Tapping such a link should fire the empty `scrollToAnchor` stub — observable as: no navigation, no error, no highlight, no external browser. (The TODO is logged for future work.) - -- [ ] **Step 5: Long-press a URL** - -A long-press on a URL inside the inline preview should trigger the chat's default URL long-press menu (Open / Copy / Share via the standard `controllerInteraction.longTap` path provided by the bubble framework for `.url` taps). The bubble's own custom long-press action sheet is out of scope for this change. - -- [ ] **Step 6: Sign-off** - -If steps 3–5 behave as described, the change is complete. If a step fails, capture: the URL used, observed behavior, and any console output. Common deviations: -- Highlight not appearing → confirm `containerNode` insertion order; the highlight node sits at index 0 below tiles, and tile background must be `.clear` (verify in `InstantPageTileNode`). -- URL resolves to nothing → confirm the upstream chat is correctly forwarding `controllerInteraction.openUrl` (this is the same wiring used by every other chat bubble). -- Anchor tap routes externally → confirm `currentLoadedWebpage()?.url == split.base` is matching; instant-page URLs sometimes carry trailing slashes in source vs. anchor links. - ---- - -## Self-review - -**Spec coverage:** -- Spec §"New private state" → Task 2 Step 1. -- Spec §"deinit disposes" → Task 2 Step 2. -- Spec §"Tap detection" → Task 4 Step 1 (textItemAtLocation, urlForTapLocation). -- Spec §"`tapActionAtPoint` body" → Task 4 Step 1 (full rewrite). -- Spec §"Concealed flag" default `true` → Task 4 Step 1 inline comment. -- Spec §"Highlight feedback" (`makeActivate`, `computeHighlightRects`, `updateLinkProgressState`) → Task 4 Step 1. -- Spec §"Item-callback wiring" → Task 3 Step 1. -- Spec §"Helpers" (`splitAnchor`, `currentLoadedWebpage`, `scrollToAnchor`) → Task 2 Step 3. -- Spec §"Verification" → Task 5 (manual smoke). -- Spec §"Out of scope" stays out of scope; no tasks added. - -All sections covered. - -**Placeholder scan:** TODO markers exist *only* inside the generated stub callbacks (intentional, per spec) and in the body of `scrollToAnchor` (intentional placeholder). No "TBD"/"fill in details"/"similar to Task N" present. - -**Type consistency:** -- `InstantPageTextItem`, `InstantPageUrlItem`, `InstantPageScrollableItem`, `InstantPageDetailsItem`, `InstantPageDetailsNode` — all referenced consistently across tasks. -- `EnginePeer` — implicit via `openPeer` callback signature (verified against `InstantPageItem.swift:18`). -- `ChatControllerInteraction.OpenUrl` initializer parameters (`url:concealed:external:message:allowInlineWebpageResolution:progress:`) — verified against `ChatControllerInteraction.swift:151`. -- `LinkHighlightingNode(color:)` and `updateRects(_:color:)` — verified against `Display/Source/LinkHighlightingNode.swift:334,346`. -- `splitAnchor` returns `(base: String, anchor: String?)`; consumers in Task 3 Step 1 and Task 4 Step 1 destructure with `let split = self.splitAnchor(...)` then access `split.base` / `split.anchor` — consistent. -- `currentLoadedWebpage()` returns `TelegramMediaWebpageLoadedContent?`; consumers use `webpage.url` which is a property of that type — consistent with the existing usage pattern at line 67 of the file. -- `urlForTapLocation` return tuple labels: `(item, urlItem, parentOffset, localPoint)`. Consumed by `tapActionAtPoint` via `urlHit.item`, `urlHit.urlItem`, `urlHit.parentOffset`, `urlHit.localPoint` — consistent. -- `computeHighlightRects` and `makeActivate` both take `(item:parentOffset:localPoint:)` — consistent. diff --git a/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md b/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md deleted file mode 100644 index af23d2627f..0000000000 --- a/docs/superpowers/plans/2026-05-01-rich-bubble-text-selection.md +++ /dev/null @@ -1,627 +0,0 @@ -# Rich-bubble text selection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all visible `InstantPageTextItem`s. - -**Architecture:** Extend `InstantPageTextItem` with a small public surface (`attributedString` + `attributesAtPoint(_:orNearest:)` + `textRangeRects(in:)`). Add a new `InstantPageMultiTextAdapter` that implements `TextNodeProtocol` by aggregating multiple items into one character-indexed view. In the rich-bubble, gate selection on `updateIsExtractedToContextPreview`, mirroring `ChatMessageTextBubbleContentNode`. - -**Tech Stack:** Swift, AsyncDisplayKit, Bazel (`build-system/Make/Make.py`); modules `Display` (TextNodeProtocol, TextRangeRectEdge), `InstantPageUI` (text item + new adapter), `TextSelectionNode`, `ChatControllerInteraction`. - -**Reference spec:** `docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md` - -**Project context:** No automated tests (per `CLAUDE.md`). Per-task verification is "Bazel build green" using: - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ - --cacheDir ~/telegram-bazel-cache \ - build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 \ - --continueOnError -``` - -The final task is a manual smoke test in the simulator. - -**Working-tree note:** The user has unrelated WIP modifications in the tree (notably `submodules/InstantPageUI/Sources/InstantPageLayout.swift`, `InstantPageControllerNode.swift`, `InstantPageTheme.swift`, plus a `lineSpacingFactor: 0.9` addition in the rich-bubble). DO NOT touch those files except for the specific edits this plan calls out. Use `git add ` — never `git add -A` / `git add .`. - ---- - -## File map - -- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote `attributedString` to public; add a new public `attributesAtPoint(_:orNearest:)` extending the existing internal one; add a new public `textRangeRects(in:)` returning `Display.TextRangeRectEdge`. -- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — new file containing `InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol`. No BUILD changes needed; the file is picked up by `glob(["Sources/**/*.swift"])` and `Display` is already a dep of `InstantPageUI`. -- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode` to `deps`. -- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add `import TextSelectionNode`, two new ivars, and the lifecycle hooks. - -No other files change. - ---- - -### Task 1: Public surface on `InstantPageTextItem` - -Three accessors become public so the adapter (defined in Task 2, in the same module) and external consumers can compose with the item. - -**Files:** -- Modify: `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` - -- [ ] **Step 1: Promote `attributedString` to public** - -Locate (around line 170): -```swift -public final class InstantPageTextItem: InstantPageItem { - let attributedString: NSAttributedString - public let lines: [InstantPageTextLine] -``` - -Replace with: -```swift -public final class InstantPageTextItem: InstantPageItem { - public let attributedString: NSAttributedString - public let lines: [InstantPageTextLine] -``` - -- [ ] **Step 2: Add public `attributesAtPoint(_:orNearest:)`** - -The existing internal method is at line 272 of `InstantPageTextItem.swift`: - -```swift - func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedString.Key: Any])? { - let transformedPoint = CGPoint(x: point.x, y: point.y) - let boundsWidth = self.frame.width - for i in 0 ..< self.lines.count { - let line = self.lines[i] - - let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) - if lineFrame.insetBy(dx: -5.0, dy: -5.0).contains(transformedPoint) { - var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) - if index == self.attributedString.length { - index -= 1 - } else if index != 0 { - var glyphStart: CGFloat = 0.0 - CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) - if transformedPoint.x < glyphStart { - index -= 1 - } - } - if index >= 0 && index < self.attributedString.length { - return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) - } - break - } - } - return nil - } -``` - -Leave that method untouched (it's still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). Immediately after it, add the new public method: - -```swift - public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { - if let direct = self.attributesAtPoint(point) { - return direct - } - guard orNearest, !self.lines.isEmpty else { - return nil - } - - let boundsWidth = self.frame.width - var nearestLineIndex = 0 - var nearestDistance = CGFloat.greatestFiniteMagnitude - for i in 0 ..< self.lines.count { - let lineFrame = expandedFrameForLine(self.lines[i], boundingWidth: boundsWidth, alignment: self.alignment) - let distance: CGFloat - if point.y < lineFrame.minY { - distance = lineFrame.minY - point.y - } else if point.y > lineFrame.maxY { - distance = point.y - lineFrame.maxY - } else { - distance = 0.0 - } - if distance < nearestDistance { - nearestDistance = distance - nearestLineIndex = i - } - } - - let line = self.lines[nearestLineIndex] - let lineFrame = expandedFrameForLine(line, boundingWidth: boundsWidth, alignment: self.alignment) - let clampedX = max(lineFrame.minX, min(lineFrame.maxX, point.x)) - var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: clampedX - lineFrame.minX, y: 0.0)) - if index == self.attributedString.length { - index -= 1 - } else if index != 0 { - var glyphStart: CGFloat = 0.0 - CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) - if clampedX - lineFrame.minX < glyphStart { - index -= 1 - } - } - guard index >= 0, index < self.attributedString.length else { - return nil - } - return (index, self.attributedString.attributes(at: index, effectiveRange: nil)) - } -``` - -- [ ] **Step 3: Add public `textRangeRects(in:)`** - -The existing internal `rangeRects(in:)` is at line 369 of the file. Leave it untouched. Add a new public method that wraps it and converts the edge type. Place it directly after the existing `rangeRects(in:)` (so the implementations live together). - -```swift - public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { - guard let result = self.rangeRects(in: range), let start = result.start, let end = result.end, !result.rects.isEmpty else { - return nil - } - let startEdge = TextRangeRectEdge(x: start.x, y: start.y, height: start.height) - let endEdge = TextRangeRectEdge(x: end.x, y: end.y, height: end.height) - return (result.rects, startEdge, endEdge) - } -``` - -`TextRangeRectEdge` is in `Display`, which is already imported by `InstantPageTextItem.swift` (verify the imports near the top of the file include `import Display` — it should). - -- [ ] **Step 4: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. - -Note: the user has unrelated WIP that may be breaking the build (a `sideInset` migration in `InstantPageLayout.swift` / `InstantPageControllerNode.swift` / `InstantPageTheme.swift`). If the build fails, check whether the failures mention `sideInset` and are inside those three files. If so, the failure is pre-existing and not from this task — flag it in your report and proceed. If failures are inside `InstantPageTextItem.swift`, those ARE from this task and need fixing. - -- [ ] **Step 5: Commit** - -```sh -git add submodules/InstantPageUI/Sources/InstantPageTextItem.swift -git commit -m "InstantPage: expose text-item attributedString and selection helpers" -``` - ---- - -### Task 2: `InstantPageMultiTextAdapter` - -A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed view, suitable for `TextSelectionNode`. - -**Files:** -- Create: `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` - -- [ ] **Step 1: Create the new file with the full adapter** - -Write `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` containing exactly: - -```swift -import Foundation -import UIKit -import AsyncDisplayKit -import Display -import TelegramCore - -public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { - private struct Entry { - let item: InstantPageTextItem - let charOffset: Int - let frameOrigin: CGPoint - } - - private let entries: [Entry] - private let combinedString: NSAttributedString - - public init(items: [InstantPageTextItem]) { - let separator = NSAttributedString(string: "\n\n") - let combined = NSMutableAttributedString() - var entries: [Entry] = [] - for (index, item) in items.enumerated() { - let charOffset = combined.length - entries.append(Entry(item: item, charOffset: charOffset, frameOrigin: item.frame.origin)) - combined.append(item.attributedString) - if index != items.count - 1 { - combined.append(separator) - } - } - self.entries = entries - self.combinedString = combined - super.init() - self.isUserInteractionEnabled = false - } - - public var currentText: NSAttributedString? { - return self.combinedString - } - - public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? { - for entry in self.entries { - let localPoint = CGPoint(x: point.x - entry.frameOrigin.x, y: point.y - entry.frameOrigin.y) - if let (localIndex, attrs) = entry.item.attributesAtPoint(localPoint, orNearest: false) { - return (entry.charOffset + localIndex, attrs) - } - } - guard orNearest, !self.entries.isEmpty else { - return nil - } - var nearestEntry = self.entries[0] - var nearestDistance = CGFloat.greatestFiniteMagnitude - for entry in self.entries { - let frame = CGRect(origin: entry.frameOrigin, size: entry.item.frame.size) - let distance: CGFloat - if point.y < frame.minY { - distance = frame.minY - point.y - } else if point.y > frame.maxY { - distance = point.y - frame.maxY - } else { - distance = 0.0 - } - if distance < nearestDistance { - nearestDistance = distance - nearestEntry = entry - } - } - let localPoint = CGPoint(x: point.x - nearestEntry.frameOrigin.x, y: point.y - nearestEntry.frameOrigin.y) - if let (localIndex, attrs) = nearestEntry.item.attributesAtPoint(localPoint, orNearest: true) { - return (nearestEntry.charOffset + localIndex, attrs) - } - return nil - } - - public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? { - var allRects: [CGRect] = [] - var startEdge: TextRangeRectEdge? - var endEdge: TextRangeRectEdge? - for entry in self.entries { - let itemLength = entry.item.attributedString.length - let entryRange = NSRange(location: entry.charOffset, length: itemLength) - let intersection = NSIntersectionRange(range, entryRange) - if intersection.length == 0 { - continue - } - let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length) - guard let result = entry.item.textRangeRects(in: localRange) else { - continue - } - for rect in result.rects { - allRects.append(rect.offsetBy(dx: entry.frameOrigin.x, dy: entry.frameOrigin.y)) - } - let translatedStart = TextRangeRectEdge(x: result.start.x + entry.frameOrigin.x, y: result.start.y + entry.frameOrigin.y, height: result.start.height) - let translatedEnd = TextRangeRectEdge(x: result.end.x + entry.frameOrigin.x, y: result.end.y + entry.frameOrigin.y, height: result.end.height) - if startEdge == nil { - startEdge = translatedStart - } - endEdge = translatedEnd - } - guard !allRects.isEmpty, let start = startEdge, let end = endEdge else { - return nil - } - return (allRects, start, end) - } -} -``` - -- [ ] **Step 2: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. The new file is picked up automatically by `glob(["Sources/**/*.swift"])` in `submodules/InstantPageUI/BUILD` (already verified). `Display` is already a dep so `TextNodeProtocol` and `TextRangeRectEdge` are reachable. - -If the build fails inside the adapter file, fix and re-build. Pre-existing `sideInset` failures elsewhere are not from this task — see Task 1's note. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift -git commit -m "InstantPage: add multi-text adapter aggregating items as a TextNodeProtocol" -``` - ---- - -### Task 3: BUILD dep, import, and ivars - -Bring `TextSelectionNode` into the rich-bubble's BUILD graph and add the two new ivars. The lifecycle methods land in Task 4. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Add `TextSelectionNode` to BUILD deps** - -Locate the deps list. Currently it ends with these entries (order approximate; you'll see `GalleryUI` and `TextLoadingEffect` already present from prior work): - -``` - "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", - "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", - "//submodules/TelegramUI/Components/ChatControllerInteraction", - "//submodules/TelegramUI/Components/TextLoadingEffect", - "//submodules/TelegramUIPreferences", - "//submodules/GalleryUI", - ], -``` - -Append `"//submodules/TextSelectionNode",` immediately before the closing `],`: - -``` - "//submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode", - "//submodules/TelegramUI/Components/Chat/ChatMessageItemCommon", - "//submodules/TelegramUI/Components/ChatControllerInteraction", - "//submodules/TelegramUI/Components/TextLoadingEffect", - "//submodules/TelegramUIPreferences", - "//submodules/GalleryUI", - "//submodules/TextSelectionNode", - ], -``` - -- [ ] **Step 2: Add the import** - -In `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift`, the imports currently look like: - -```swift -import Foundation -import UIKit -import AsyncDisplayKit -import Display -import TelegramCore -import Postbox -import SwiftSignalKit -import AccountContext -import ChatMessageBubbleContentNode -import ChatMessageItemCommon -import ChatControllerInteraction -import InstantPageUI -import TelegramUIPreferences -import TextLoadingEffect -``` - -Append `import TextSelectionNode` immediately after `import TextLoadingEffect`: - -```swift -import Foundation -import UIKit -import AsyncDisplayKit -import Display -import TelegramCore -import Postbox -import SwiftSignalKit -import AccountContext -import ChatMessageBubbleContentNode -import ChatMessageItemCommon -import ChatControllerInteraction -import InstantPageUI -import TelegramUIPreferences -import TextLoadingEffect -import TextSelectionNode -``` - -(There may be additional imports below `TextLoadingEffect` from prior work, e.g. `GalleryUI`. If so, place `import TextSelectionNode` after them — order within the import block doesn't matter as long as you don't break alphabetization that already exists.) - -- [ ] **Step 3: Add the two new ivars** - -Locate the existing block of stored properties (currently around lines 27-32 — the `linkProgress*` and `linkHighlightingNode` group): - -```swift - private var linkProgressDisposable: Disposable? - private var linkProgressRects: [CGRect]? - private var linkHighlightingNode: LinkHighlightingNode? - private var linkProgressView: TextLoadingEffectView? -``` - -Append the two new ivars immediately after `linkProgressView`: - -```swift - private var linkProgressDisposable: Disposable? - private var linkProgressRects: [CGRect]? - private var linkHighlightingNode: LinkHighlightingNode? - private var linkProgressView: TextLoadingEffectView? - private var textSelectionAdapter: InstantPageMultiTextAdapter? - private var textSelectionNode: TextSelectionNode? -``` - -- [ ] **Step 4: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. Unused `import TextSelectionNode` and unused private ivars don't trigger Swift warnings, so `-warnings-as-errors` stays clean. - -- [ ] **Step 5: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: add TextSelectionNode dep and selection ivars" -``` - ---- - -### Task 4: Lifecycle hooks for entering / leaving context preview - -Override `updateIsExtractedToContextPreview(_:)` to set up the adapter + `TextSelectionNode` when the bubble is lifted into the context-menu preview, and `willUpdateIsExtractedToContextPreview(_:)` to tear them down when it leaves. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Replace the empty preview-lifecycle stubs** - -The file currently contains two empty overrides (around the area near `updateSearchTextHighlightState`): - -```swift - override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { - } - - override public func updateIsExtractedToContextPreview(_ value: Bool) { - } -``` - -Replace BOTH with: - -```swift - override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { - if !value, let textSelectionNode = self.textSelectionNode { - self.textSelectionNode = nil - self.textSelectionAdapter = nil - textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in - textSelectionNode?.highlightAreaNode.removeFromSupernode() - textSelectionNode?.removeFromSupernode() - }) - } - } - - override public func updateIsExtractedToContextPreview(_ value: Bool) { - guard value, self.textSelectionNode == nil, let messageItem = self.item, let layout = self.currentPageLayout?.layout, let rootNode = messageItem.controllerInteraction.chatControllerNode() else { - return - } - - let items = layout.items.compactMap { $0 as? InstantPageTextItem }.filter { $0.selectable && !$0.attributedString.string.isEmpty } - guard !items.isEmpty else { - return - } - - let adapter = InstantPageMultiTextAdapter(items: items) - adapter.frame = self.containerNode.bounds - self.textSelectionAdapter = adapter - self.containerNode.addSubnode(adapter) - - let incoming = messageItem.message.effectivelyIncoming(messageItem.context.account.peerId) - let theme = messageItem.presentationData.theme.theme - let selectionColor = incoming ? theme.chat.message.incoming.textSelectionColor : theme.chat.message.outgoing.textSelectionColor - let knobColor = incoming ? theme.chat.message.incoming.textSelectionKnobColor : theme.chat.message.outgoing.textSelectionKnobColor - - let textSelectionNode = TextSelectionNode( - theme: TextSelectionTheme(selection: selectionColor, knob: knobColor, isDark: theme.overallDarkAppearance), - strings: messageItem.presentationData.strings, - textNodeOrView: .node(adapter), - updateIsActive: { _ in }, - present: { [weak self] c, a in - guard let self, let item = self.item else { - return - } - if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { - item.controllerInteraction.presentControllerInCurrent(c, a) - } else { - item.controllerInteraction.presentGlobalOverlayController(c, a) - } - }, - rootView: { [weak rootNode] in - return rootNode?.view - }, - performAction: { [weak self] text, action in - guard let self, let item = self.item else { - return - } - item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action) - } - ) - - let enableCopy = (!messageItem.associatedData.isCopyProtectionEnabled && !messageItem.message.isCopyProtected()) || messageItem.message.id.peerId.isVerificationCodes - textSelectionNode.enableCopy = enableCopy - textSelectionNode.enableQuote = false - textSelectionNode.enableTranslate = true - textSelectionNode.enableShare = true - textSelectionNode.enableLookup = true - - textSelectionNode.frame = self.containerNode.bounds - textSelectionNode.highlightAreaNode.frame = self.containerNode.bounds - self.containerNode.addSubnode(textSelectionNode.highlightAreaNode) - self.containerNode.addSubnode(textSelectionNode) - self.textSelectionNode = textSelectionNode - } -``` - -Notes on this block: -- `chatControllerNode()` returns `ASDisplayNode?` from `ChatControllerInteraction`. The `rootView` closure weakly captures it. -- `TextSelectionTheme(selection:knob:isDark:)` — verified against `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:66`. -- `TextSelectionNode(theme:strings:textNodeOrView:updateIsActive:present:rootView:externalKnobSurface:performAction:)` — verified against the same file at line 296. We omit `externalKnobSurface` (defaulted to `nil`). -- `controllerInteraction.performTextSelectionAction(_:_:_:_:_:)` signature `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` — verified against `ChatControllerInteraction.swift:263`. -- The `subject` pattern `case let .messageOptions(_, _, info) = subject, case .reply = info` mirrors `ChatMessageTextBubbleContentNode.swift:1651-1654`. -- `enableLookup` defaults to `true` on `TextSelectionNode`; we set it explicitly for clarity. -- The capture `[weak self]` in `present` and `performAction` is enough; we don't need to retain `self` from inside those closures because the bubble node stays alive while the selection node does. - -- [ ] **Step 2: Build to verify** - -```sh -source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError -``` - -Expected: build succeeds. - -Common likely errors and fixes: -- "value of type 'ChatMessageItemAssociatedData' has no member 'isCopyProtectionEnabled'" → property name has changed; grep `submodules/TelegramUI/Components/Chat/ChatMessageItemCommon/Sources/` for the canonical name and substitute. -- "value of type 'PeerId' has no member 'isVerificationCodes'" → grep `submodules/TelegramCore/Sources/` for `isVerificationCodes` and import the right module if missing. -- "instance method 'presentControllerInCurrent' requires…" / similar — both `presentControllerInCurrent` and `presentGlobalOverlayController` exist on `ChatControllerInteraction` (lines 219 and 222 of `ChatControllerInteraction.swift`); their signatures take `(ViewController, Any?)`. -- Missing pre-existing failures from the user's `sideInset` migration are NOT from this task — see Task 1 Step 4's note. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "Rich bubble: drag-handle text selection in context-preview mode" -``` - ---- - -### Task 5: Manual smoke verification - -There are no automated tests for chat UI. Final task is a hands-on smoke test in the simulator. - -**Files:** none modified. Verification only. - -- [ ] **Step 1: Launch the app in the simulator** - -The build command run in earlier tasks produces the app binary; launch via Xcode (open `Telegram-iOS.xcworkspace` if the workspace exists locally) or run the Bazel-produced target. Sign in to a real test account. - -- [ ] **Step 2: Find or send a message with a rich-data IV preview** - -Send a Telegraph or t.me URL that produces an instant-view preview. The `debugRichText` experimental setting must be enabled for the preview to render via `ChatMessageRichDataBubbleContentNode` instead of the standard webpage card. (`ChatMessageBubbleItemNode.swift:386-387` is the gate.) - -- [ ] **Step 3: Long-press the bubble** - -Long-press the rich-data bubble. The framework lifts it into the context-preview popover, the message context menu appears alongside. - -- [ ] **Step 4: Drag-select text** - -Tap and drag inside the lifted preview. Drag-handles (knobs) should appear; the selection should extend across paragraphs as you drag. The selection background uses the incoming/outgoing `textSelectionColor` from the current theme. - -- [ ] **Step 5: Verify the action menu** - -The action menu (Copy / Translate / Share / Speak / Look Up) should appear anchored on the selection. Verify: -- Copy: places the selected text on the pasteboard. Multi-paragraph selections include `\n\n` between paragraphs. -- Quote menu item is **not** present (we explicitly disabled it). -- Translate / Share / Speak / Look Up route through the standard chat flow and behave normally. - -- [ ] **Step 6: Dismiss the context preview** - -Tap outside the preview or scroll away. The selection overlay and knobs fade out cleanly (alpha 1→0 over 0.2s), and the bubble returns to its normal state. Re-opening the context preview should re-create the selection nodes from scratch. - -- [ ] **Step 7: Sign-off** - -If steps 4–6 behave as described, the change is complete. If any step fails, capture: which step, observed vs. expected, any console output. Common deviations: -- Knobs never appear → confirm `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` are both `containerNode.bounds`. If `containerNode.bounds.size` is zero at the moment of preview entry, the selection node has nothing to draw on. -- Selection rects don't line up with text → confirm `adapter.frame.origin` is `.zero` and item frames in the layout are in `containerNode`-local coords (the layout origin is `(0, 0)` inside `containerNode`; the `(1, 1)` outer offset doesn't apply here because the selection nodes live INSIDE `containerNode`). -- Cross-paragraph drag stops at paragraph boundaries → confirm `attributesAtPoint` falls through to the nearest-entry branch when `orNearest == true`. -- Copy includes wrong text → confirm `combinedString` interleaves `"\n\n"` separators between items. - ---- - -## Self-review - -**Spec coverage:** -- Spec §"API exposure on `InstantPageTextItem`" → Task 1 (all three accessors). -- Spec §"`InstantPageMultiTextAdapter`" → Task 2 (full adapter). -- Spec §"Rich-bubble lifecycle wiring" — ivars/import/BUILD dep → Task 3; `updateIsExtractedToContextPreview` / `willUpdateIsExtractedToContextPreview` → Task 4. -- Spec §"Verification" → Task 5 (manual smoke). -- Spec §"Out of scope" stays out of scope; no tasks added. - -All spec sections covered. - -**Placeholder scan:** None of the "TBD" / "TODO" / "implement later" / "similar to Task N" patterns are present. Each step shows the actual code or command. - -**Type consistency:** -- `InstantPageMultiTextAdapter` is named the same in Task 2 (definition) and Tasks 3–4 (consumers). -- `Entry` struct is internal to the adapter and not referenced externally. -- `InstantPageTextItem.attributesAtPoint(_:orNearest:)` and `textRangeRects(in:)` are defined in Task 1 with the same signatures the adapter calls in Task 2. -- `TextSelectionNode` initializer parameters in Task 4 match the verified signature from `submodules/TextSelectionNode/Sources/TextSelectionNode.swift:296` (`theme`, `strings`, `textNodeOrView`, `updateIsActive`, `present`, `rootView`, `performAction` — no `externalKnobSurface`). -- `TextSelectionTheme(selection:knob:isDark:)` matches the verified init at line 66 of the same file. -- `controllerInteraction.performTextSelectionAction` invocation matches `(Message?, Bool, NSAttributedString, [MessageTextEntity]?, TextSelectionAction)` from line 263 of `ChatControllerInteraction.swift`. -- `subject` pattern `.messageOptions(_, _, info)` with `case .reply = info` mirrors text-bubble exactly. diff --git a/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md b/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md deleted file mode 100644 index bcdfa83373..0000000000 --- a/docs/superpowers/plans/2026-05-01-rich-data-bubble-scroll-to-anchor.md +++ /dev/null @@ -1,504 +0,0 @@ -# Rich-Data Bubble scrollToAnchor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `ChatMessageRichDataBubbleContentNode.scrollToAnchor` actually scroll the chat history so that an in-page anchor's line lands at the top of the visible content area. - -**Architecture:** Mirror the existing `getQuoteRect` mechanism. The rich-data bubble exposes `getAnchorRect(anchor:)`, the bubble item node forwards to it. A new `ChatControllerInteraction.scrollToMessageIdWithAnchor` closure walks visible items via `forEachVisibleItemNode` (the bubble is necessarily at least partially visible because the user tapped a link in it), reads the anchor's item-local y, and routes through `ChatHistoryListNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom` places the item so the anchor lands at the visual top of the content area; it works uniformly for short and tall items, where `.center(.custom)` is bypassed for items that fit in the content area. - -**Tech Stack:** Swift, Bazel build, AsyncDisplayKit. No unit tests in this project — verification is a full Bazel build. - -**Spec:** [docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md](../specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md) - -**Build command (run after each task):** - -```sh -source ~/.zshrc 2>/dev/null; \ -python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -After Tasks 1–3 the build must be green; the feature is not yet live (no callers use the new methods). After Task 4 the new closure exists and is implemented but the bubble still routes through the old stub. After Task 5 the feature is live. - ---- - -## Task 1: Add base `getAnchorRect` to `ChatMessageBubbleContentNode` - -This makes `getAnchorRect(anchor:)` callable on every content node (returns `nil` by default) so the iteration in `ChatMessageBubbleItemNode` doesn't need a type-test. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift` - -- [ ] **Step 1: Add the base method** - -Open the file. Find the existing `open func transitionNode(messageId:media:adjustRect:) -> (...)` definition (around line 261). Add a new method directly after its closing brace: - -```swift -open func getAnchorRect(anchor: String) -> CGRect? { - return nil -} -``` - -The result is that the file should contain, contiguously: - -```swift -open func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - return nil -} - -open func getAnchorRect(anchor: String) -> CGRect? { - return nil -} - -open func updateHiddenMedia(_ media: [Media]?) -> Bool { - return false -} -``` - -- [ ] **Step 2: Build** - -Run the build command at the top of this plan. -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift -git commit -m "$(cat <<'EOF' -ChatMessageBubbleContentNode: add base getAnchorRect - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Override `getAnchorRect` in `ChatMessageRichDataBubbleContentNode` - -Walks the cached instant-page layout and returns the rect of an anchor (in the bubble content node's coordinate space) without triggering any side-effects. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Add the override and recursive helper** - -Open the file. Find the existing `private func splitAnchor(_ url: String)` (around line 774). Insert two new methods directly above it (so the new public override comes before the private string helpers but after `findInstantPageMedia`): - -```swift -override public func getAnchorRect(anchor: String) -> CGRect? { - guard let layout = self.currentPageLayout?.layout else { - return nil - } - if let rect = self.anchorRect(in: layout.items, anchor: anchor, baseY: 0.0) { - // Translate from layout/containerNode coords to bubble-content-node coords. - // containerNode is offset by (1, 1) from the bubble content node. - return rect.offsetBy(dx: 1.0, dy: 1.0) - } - return nil -} - -private func anchorRect(in items: [InstantPageItem], anchor: String, baseY: CGFloat) -> CGRect? { - for item in items { - if let item = item as? InstantPageAnchorItem, item.anchor == anchor { - return CGRect(x: item.frame.minX, y: baseY + item.frame.minY, width: 1.0, height: 1.0) - } else if let item = item as? InstantPageTextItem { - if let (lineIndex, _) = item.anchors[anchor] { - let lineFrame = item.lines[lineIndex].frame - return CGRect(x: item.frame.minX + lineFrame.minX, y: baseY + item.frame.minY + lineFrame.minY, width: lineFrame.width, height: lineFrame.height) - } - } else if let item = item as? InstantPageTableItem { - if let (offset, _) = item.anchors[anchor] { - return CGRect(x: item.frame.minX, y: baseY + item.frame.minY + offset, width: item.frame.width, height: 1.0) - } - } else if let item = item as? InstantPageDetailsItem { - // Inner items are laid out below the title bar, so the recursive base - // must include titleHeight (mirrors InstantPageDetailsNode.linkSelectionRects). - if let rect = self.anchorRect(in: item.items, anchor: anchor, baseY: baseY + item.frame.minY + item.titleHeight) { - return rect - } - } - } - return nil -} -``` - -Note: the existing file already imports `InstantPageUI`, so `InstantPageItem`, `InstantPageAnchorItem`, `InstantPageTextItem`, `InstantPageTableItem`, and `InstantPageDetailsItem` resolve. `InstantPageTextItem.anchors` is typed `[String: (Int, Bool)]`, `InstantPageTableItem.anchors` is `[String: (CGFloat, Bool)]` — destructure accordingly. - -- [ ] **Step 2: Build** - -Run the build command. -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "$(cat <<'EOF' -Rich bubble: add getAnchorRect override - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Forward `getAnchorRect` from `ChatMessageBubbleItemNode` - -Iterates content nodes and converts the rect to the bubble item node's coordinate space. Mirrors the existing `getQuoteRect` shape exactly. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift` - -- [ ] **Step 1: Add the public forwarder** - -Open the file. Find the existing `public func getQuoteRect(quote: String, offset: Int?) -> CGRect?` (around line 7237). Insert a new method directly after its closing brace, before `public func getInnerReplySubjectRect(...)`: - -```swift -public func getAnchorRect(anchor: String) -> CGRect? { - for contentNode in self.contentNodes { - if let result = contentNode.getAnchorRect(anchor: anchor) { - return contentNode.view.convert(result, to: self.view) - } - } - return nil -} -``` - -- [ ] **Step 2: Build** - -Run the build command. -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift -git commit -m "$(cat <<'EOF' -Bubble item: forward getAnchorRect to content nodes - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: Add `scrollToMessageIdWithAnchor` closure (declaration + 7 sites) - -Adds the new `(MessageIndex, String) -> Void` closure to `ChatControllerInteraction`, the real implementation in `ChatController.swift`, and no-op stubs at the six other call sites. After this task the build is green and the closure works end-to-end on the chat-controller side; the rich-data bubble still routes through the old stub so the feature is not yet live. - -**Files (8 edits):** -- Modify: `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` (3 edits: field, init param, assignment) -- Modify: `submodules/TelegramUI/Sources/ChatController.swift` (real implementation) -- Modify: `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift` (no-op stub) -- Modify: `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` (no-op stub) -- Modify: `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` (no-op stub) -- Modify: `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` (no-op stub) -- Modify: `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift` (no-op stub) -- Modify: `submodules/TelegramUI/Sources/SharedAccountContext.swift` (no-op stub) - -- [ ] **Step 1: Add the field on `ChatControllerInteraction`** - -In `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift`, find: - -```swift -public let scrollToMessageId: (MessageIndex, CGFloat) -> Void -``` - -(around line 311). Insert directly after it: - -```swift -public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void -``` - -- [ ] **Step 2: Add the init parameter** - -In the same file, find the init parameter list (around line 490): - -```swift -scrollToMessageId: @escaping (MessageIndex, CGFloat) -> Void, -``` - -Insert directly after it: - -```swift -scrollToMessageIdWithAnchor: @escaping (MessageIndex, String) -> Void, -``` - -- [ ] **Step 3: Add the init assignment** - -In the same file, find (around line 622): - -```swift -self.scrollToMessageId = scrollToMessageId -``` - -Insert directly after it: - -```swift -self.scrollToMessageIdWithAnchor = scrollToMessageIdWithAnchor -``` - -- [ ] **Step 4: Add real implementation in `ChatController.swift`** - -In `submodules/TelegramUI/Sources/ChatController.swift`, find (around line 5397): - -```swift -}, scrollToMessageId: { [weak self] index, offset in - self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset) -}, navigateToStory: { [weak self] message, storyId in -``` - -Insert a new closure between `scrollToMessageId` and `navigateToStory`: - -```swift -}, scrollToMessageId: { [weak self] index, offset in - self?.chatDisplayNode.historyNode.scrollToMessage(index: index, offset: offset) -}, scrollToMessageIdWithAnchor: { [weak self] index, anchor in - guard let self else { - return - } - var anchorY: CGFloat? - self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in - guard anchorY == nil else { - return - } - if let itemNode = itemNode as? ChatMessageBubbleItemNode, - itemNode.item?.message.id == index.id, - let rect = itemNode.getAnchorRect(anchor: anchor) { - anchorY = rect.minY - } - } - if let anchorY { - self.chatDisplayNode.historyNode.scrollToMessage( - from: index, to: index, - animated: true, highlight: false, - scrollPosition: .bottom(anchorY) - ) - } else { - self.chatDisplayNode.historyNode.scrollToMessage(index: index) - } -}, navigateToStory: { [weak self] message, storyId in -``` - -`scrollToMessage(from:to:animated:highlight:scrollPosition:)` is the existing public method on `ChatHistoryListNode` (declared at line 3585 in `ChatHistoryListNode.swift`); `quote`, `subject`, and `setupReply` use their default values. `ChatMessageBubbleItemNode` is already imported at the top of `ChatController.swift`. The `forEachVisibleItemNode` walk is sound because tapping the in-page anchor link requires the bubble to be at least partially visible. - -- [ ] **Step 5: Add no-op stub in `BrowserBookmarksScreen.swift`** - -In `submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift`, find (around line 183): - -```swift -}, scrollToMessageId: { _, _ in -``` - -Replace with: - -```swift -}, scrollToMessageId: { _, _ in -}, scrollToMessageIdWithAnchor: { _, _ in -``` - -- [ ] **Step 6: Add no-op stub in `ChatRecentActionsControllerNode.swift`** - -In `submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift`, find (around line 660): - -```swift -}, scrollToMessageId: { _, _ in -``` - -Replace with: - -```swift -}, scrollToMessageId: { _, _ in -}, scrollToMessageIdWithAnchor: { _, _ in -``` - -- [ ] **Step 7: Add no-op stub in `ChatSendAudioMessageContextPreview.swift`** - -In `submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift`, find (around line 507): - -```swift -}, scrollToMessageId: { _, _ in -``` - -Replace with: - -```swift -}, scrollToMessageId: { _, _ in -}, scrollToMessageIdWithAnchor: { _, _ in -``` - -- [ ] **Step 8: Add no-op stub in `PeerInfoScreen.swift`** - -In `submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift`, find (around line 1278): - -```swift -}, scrollToMessageId: { _, _ in -``` - -Replace with: - -```swift -}, scrollToMessageId: { _, _ in -}, scrollToMessageIdWithAnchor: { _, _ in -``` - -- [ ] **Step 9: Add no-op stub in `OverlayAudioPlayerControllerNode.swift`** - -In `submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift`, find (around line 252): - -```swift -}, scrollToMessageId: { _, _ in -``` - -Replace with: - -```swift -}, scrollToMessageId: { _, _ in -}, scrollToMessageIdWithAnchor: { _, _ in -``` - -- [ ] **Step 10: Add no-op stub in `SharedAccountContext.swift`** - -In `submodules/TelegramUI/Sources/SharedAccountContext.swift`, find (around line 2565): - -```swift -scrollToMessageId: { _, _ in -``` - -(Note: this site has no leading `}, ` because it is the first argument on its line — verify with `grep -n "scrollToMessageId:" submodules/TelegramUI/Sources/SharedAccountContext.swift`.) - -Insert a new closure directly after the closing `}` of the `scrollToMessageId` stub. If the original looks like: - -```swift -scrollToMessageId: { _, _ in -}, -navigateToStory: { _, _ in -``` - -it should become: - -```swift -scrollToMessageId: { _, _ in -}, -scrollToMessageIdWithAnchor: { _, _ in -}, -navigateToStory: { _, _ in -``` - -Match the surrounding indentation and trailing-comma style of the file. - -- [ ] **Step 11: Build** - -Run the build command. -Expected: build succeeds. Any compile error in this task means a stub site was missed or the closure type was mismatched — search for `scrollToMessageId:` again and confirm every site has a corresponding `scrollToMessageIdWithAnchor:`. - -- [ ] **Step 12: Commit** - -```sh -git add \ - submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift \ - submodules/TelegramUI/Sources/ChatController.swift \ - submodules/BrowserUI/Sources/BrowserBookmarksScreen.swift \ - submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift \ - submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift \ - submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift \ - submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift \ - submodules/TelegramUI/Sources/SharedAccountContext.swift -git commit -m "$(cat <<'EOF' -ChatControllerInteraction: add scrollToMessageIdWithAnchor closure - -Routes through ChatHistoryListNode.scrollToMessage with a custom -.center(.custom) callback that asks the bubble item for the anchor -rect's midY. Six existing no-op interaction sites get matching -no-op stubs. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Wire up `scrollToAnchor` in the rich-data bubble - -Replace the stub body so that taps on in-page anchor links actually scroll. After this task the feature is live end-to-end. - -**Files:** -- Modify: `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` - -- [ ] **Step 1: Replace `scrollToAnchor` body** - -In `ChatMessageRichDataBubbleContentNode.swift`, find (around line 796): - -```swift -private func scrollToAnchor(_ anchor: String) { - guard let item = self.item else { - return - } - // 0.0 is offset - item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) -} -``` - -Replace with: - -```swift -private func scrollToAnchor(_ anchor: String) { - guard let item = self.item else { - return - } - if anchor.isEmpty { - item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) - } else { - item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) - } -} -``` - -The empty-anchor branch keeps the existing "scroll to message top" behavior for `#` URLs with no fragment. - -- [ ] **Step 2: Build** - -Run the build command. -Expected: build succeeds. - -- [ ] **Step 3: Manual smoke test** - -This project has no unit tests. Smoke-test in the simulator: - -1. Launch the app on the iOS simulator. -2. Open a chat that contains a webpage message rendered as a rich-data bubble. Good source: a Wikipedia article URL whose Telegram instant-page render contains in-page section/footnote links (e.g., the "Contents" section or the `[1]`-style citation links). -3. Tap a section/footnote link inside the bubble. -4. Expected: the chat scrolls so that the target line of the bubble is centered in the visible area. If the bubble is partially off-screen, the chat scrolls to bring the line into view. -5. Tap a `#`-only link (no fragment) if you can find one. Expected: chat scrolls to the message top (existing pre-task behavior preserved). - -If the scroll doesn't land where expected, double-check the coord conversions in Task 2 (`+1, +1` for `containerNode` inset) and Task 3 (`contentNode.view.convert(rect, to: self.view)`). - -- [ ] **Step 4: Commit** - -```sh -git add submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift -git commit -m "$(cat <<'EOF' -Rich bubble: scrollToAnchor scrolls to anchor's line - -Empty anchor keeps the previous scroll-to-message-top behavior. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Done - -All five tasks complete leaves: -- Each commit independently builds. -- The feature is live: tapping an in-page anchor link inside a rich-data bubble scrolls the chat to center the target line. -- Reference popups and details expansion are deferred (per spec). diff --git a/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md b/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md deleted file mode 100644 index 09952f55d1..0000000000 --- a/docs/superpowers/specs/2026-04-30-typing-draft-send-delay-design.md +++ /dev/null @@ -1,178 +0,0 @@ -# Typing-Draft Send Delay — Design - -**Date:** 2026-04-30 -**Component:** `submodules/TelegramCore/Sources/State/PendingMessageManager.swift` (+ minimal Postbox additions) - -## Goal - -Delay outgoing messages while the peer in the same `(peerId, threadId)` is "live-typing" an incoming message (i.e. `Postbox.combinedView(keys: [.typingDrafts(...)])` reports a non-nil draft for that key). Messages park after their content is fully uploaded, then drain in `messageId.id` order once the typing-draft for that key clears. - -## Behavior summary - -- **Scope.** All "deliver-now" outgoing message types: regular text/media single sends, grouped media albums, and forwards. Excluded: scheduled messages, secret-chat messages, and Saved Messages (account-self peer). -- **Pipeline.** Uploads run in parallel as they do today. The gate sits between "upload complete" and the actual MTProto send call. -- **Release.** As soon as the typing-draft for the message's `(peerId, threadId)` clears (the view's set no longer contains that key) — no extra grace delay, no upper-bound timeout. -- **Keying.** Strictly per-thread. `threadId == nil` is the normal value for non-threaded chats and gates with `PeerAndThreadId(peerId: ..., threadId: nil)`. The `Message.newTopicThreadId` sentinel does not gate (already handled by `.waitingForNewTopic`). -- **Always-on.** No preference toggle. -- **Composes with paid-message postpone.** Paid postpone gates upload-start; typing-draft gate gates post-upload send. Both must be clear before send. - -## Architecture - -All logic lives in `PendingMessageManager`. Postbox gains one new public view; no other Postbox API changes. - -### Postbox additions - -1. New file `submodules/Postbox/Sources/AllTypingDraftsView.swift`: - - `MutableAllTypingDraftsView: MutablePostboxView` - - `init(postbox:)` seeds `keys` from `postbox.currentTypingDrafts.keys`. - - `replay(postbox:transaction:)` diffs against `transaction.updatedTypingDrafts`: insert key when `update.value != nil`, remove when nil. Returns `true` if the set changed. - - `refreshDueToExternalTransaction(postbox:)` reloads from `postbox.currentTypingDrafts.keys` and returns `true`. - - `immutableView()` returns an `AllTypingDraftsView`. - - `public final class AllTypingDraftsView: PostboxView` exposes `public let keys: Set`. -2. `submodules/Postbox/Sources/Views.swift`: - - Add `case allTypingDrafts` to `PostboxViewKey` (no associated payload). - - Wire constant `Hashable` combine and `==` matching for the new case. - - Add the `case .allTypingDrafts` arm to `postboxViewForKey` returning `MutableAllTypingDraftsView(postbox:)`. -3. `PostboxImpl.currentTypingDrafts` is `fileprivate(set)`, accessible from view files in the same module. No new accessor needed. - -### PendingMessageManager additions - -New `PendingMessageState` case: - -```swift -case waitingForSendGate(groupId: Int64?, content: PendingMessageUploadedContentAndReuploadInfo) -``` - -Added to `PendingMessageState.groupId`'s switch. Excluded from `updatePendingMediaUploads`'s upload-progress aggregation. - -New stored state on the manager: - -```swift -private var liveTypingDraftKeys: Set = [] -private let allTypingDraftsDisposable = MetaDisposable() -private var forwardSendGateGroups: [PeerAndThreadId: [[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]]] = [:] -``` - -In `init`, subscribe once: - -```swift -self.allTypingDraftsDisposable.set( - (postbox.combinedView(keys: [.allTypingDrafts]) - |> deliverOn(self.queue)).start(next: { [weak self] view in - self?.handleLiveTypingDraftsUpdate(view) - }) -) -``` - -Dispose in `deinit`. - -## Gate predicate - -```swift -private func isSendGateOpen(for key: PeerAndThreadId) -> Bool { - return !self.liveTypingDraftKeys.contains(key) -} - -private func shouldGateSend(messageId: MessageId, threadId: Int64?) -> Bool { - if messageId.namespace == Namespaces.Message.ScheduledCloud { return false } - if messageId.peerId.namespace == Namespaces.Peer.SecretChat { return false } - if messageId.peerId == self.accountPeerId { return false } - if threadId == Message.newTopicThreadId { return false } - return true -} -``` - -A pending context is gate-applicable if `shouldGateSend(...)` returns true. The gate is open if `isSendGateOpen(...)` returns true. Sites delay the send only when `shouldGateSend && !isSendGateOpen`. - -## Gate insertion points - -### (a) Single-message — `beginSendingMessage(messageContext:messageId:groupId:content:)` - -Today: `groupId == nil → commitSendingSingleMessage`; otherwise `state = .waitingToBeSent(groupId: ..., content: ...)`. - -New: when `groupId == nil`, additionally check the gate: - -```swift -let key = PeerAndThreadId(peerId: messageId.peerId, threadId: messageContext.threadId) -if shouldGateSend(messageId: messageId, threadId: messageContext.threadId) && !isSendGateOpen(for: key) { - messageContext.state = .waitingForSendGate(groupId: nil, content: content) -} else { - self.commitSendingSingleMessage(messageContext: messageContext, messageId: messageId, content: content) -} -``` - -The grouped path (`groupId != nil`) is unchanged here; gating for albums happens in (b). - -### (b) Grouped-album — `commitSendingMessageGroup(groupId:messages:)` - -Today: flips every group context to `.sending(groupId:)`, fires `sendGroupMessagesContent`. - -New: derive a representative key from the first message's `(peerId, threadId)`. (Group members share both by construction.) If `shouldGateSend && !isSendGateOpen`, flip every group context to `.waitingForSendGate(groupId: groupId, content: )` and return. Otherwise unchanged. - -`dataForPendingMessageGroup(_ groupId:)` is updated to recognize `.waitingForSendGate(groupId: contextGroupId, ...)` the same way it recognizes `.waitingToBeSent` — i.e. a group becomes "ready" when every member is in `.waitingToBeSent` OR `.waitingForSendGate`. This prevents partial-park deadlocks. - -### (c) Forwards — inside `beginSendingMessages`, lines 714–733 - -Today: builds `countedMessageGroups` and immediately fires `sendGroupMessagesContent` per group. - -The pre-existing `messagesToForward` bucketing is by `PeerIdAndNamespace` only — not by `threadId`. The downstream `sendGroupMessagesContent` network call requires thread homogeneity (a forward dispatch targets a single destination thread), so in practice every group already shares `threadId`. The gate uses this assumption: derive the key from `messages[0].1.threadId` of each `countedMessageGroup`. If a future caller violates the assumption, the existing dispatch path is already broken. - -New: per group, derive `key = PeerAndThreadId(peerId: messages[0].1.id.peerId, threadId: messages[0].1.threadId)`. If `shouldGateSend && !isSendGateOpen`, flip every context in the group to `.waitingForSendGate(groupId: nil, content: PendingMessageUploadedContentAndReuploadInfo(content: .forward(forwardInfo), reuploadInfo: nil, cacheReferenceKey: nil))` and append the entire `[(PendingMessageContext, Message, ForwardSourceInfoAttribute)]` group to `forwardSendGateGroups[key]`. Otherwise fire as today. - -Forward groups within a key drain in FIFO order. - -## Drain logic - -`drainSendGate(key: PeerAndThreadId)` runs on `self.queue`. Idempotent. - -1. **Single-message drain.** Snapshot `messageContexts` filtering on `state == .waitingForSendGate(groupId: nil, ...)` AND `PeerAndThreadId(peerId: contextId.peerId, threadId: context.threadId) == key`. Sort by `messageId.id` ascending. For each, extract the parked `content`, call `commitSendingSingleMessage(messageContext:messageId:content:)`. -2. **Grouped-album drain.** Collect distinct `groupId`s among `.waitingForSendGate(groupId: , ...)` contexts whose key matches. Iterate in ascending min-`messageId.id`-in-group order. For each, call `dataForPendingMessageGroup(groupId)` (which now sees the parked members as ready) and pass the result to `commitSendingMessageGroup(groupId:messages:)`. -3. **Forward drain.** Pop `forwardSendGateGroups.removeValue(forKey: key)`. For each parked group (FIFO): flip every context to `.sending(groupId: nil)`, build the `[(MessageId, PendingMessageUploadedContentAndReuploadInfo)]` array, fire `sendGroupMessagesContent` exactly mirroring the existing forward-fire code (lines 719–731). -4. After (1)–(3), call `updateWaitingUploads(peerId: key.peerId)` and `updatePendingMediaUploads()` once. - -`handleLiveTypingDraftsUpdate(_ view: CombinedView)`: - -```swift -let view = (view.views[.allTypingDrafts] as? AllTypingDraftsView) -let new = view?.keys ?? [] -let cleared = self.liveTypingDraftKeys.subtracting(new) -self.liveTypingDraftKeys = new -for key in cleared { - self.drainSendGate(key: key) -} -``` - -Single-emission semantics: a `(false → true)` transition (key newly populated) parks future arrivals only; in-flight `.sending` continues. A `(true → false)` transition fires drain. - -## Side effects on existing helpers - -- `PendingMessageState.groupId` switch (line 37): add `.waitingForSendGate` case returning the case's `groupId` (forward parking uses `groupId == nil`; grouped-album parking uses the real groupId). -- `updatePendingMediaUploads()` (line 262): `.waitingForSendGate` is **not** treated as uploading. Excluded from the switch (or explicitly returns `default` early). -- `dataForPendingMessageGroup(_ groupId:)` (line 753): add `.waitingForSendGate(contextGroupId, content)` arm — if `contextGroupId == groupId`, append `(context, id, content)` to result, same as the existing `.waitingToBeSent` arm. -- `updatePendingMessageIds(_:)` (line 284): in the existing `for id in removedMessageIds` loop, additionally drop `forwardSendGateGroups[*]` entries whose contained context matches `id`. (Single/album parking is auto-cleaned because parked state lives on the context, which gets `state = .none`.) Implementation: walk the dict, filter out the removed context from each parked group, drop any group that empties out, drop any key whose value-array empties out. - -## Edge cases - -- **First-emit race.** `liveTypingDraftKeys` initializes to `[]`. If a send is attempted before the first view emit and a draft is actually active, that single message slips through. Tolerated. -- **Saved Messages / secret chats / scheduled / new-topic sentinel.** All explicit skip-cases in `shouldGateSend`. -- **Self-typing on another device.** A draft we authored on another device is treated like any other — our outgoing send to that chat parks until it clears. This is consistent with the design intent (drafts visibly commit before subsequent sends arrive). No author filter. -- **Removed-while-parked.** Handled by `updatePendingMessageIds(_:)` extension above. -- **Re-entrancy.** Drain helpers snapshot work-lists before iterating, so mid-iteration mutations to `messageContexts` (e.g. a fired send completes synchronously) don't corrupt the loop. -- **Paid postpone composition.** Paid postpone gates upload-start; once paid commit fires, upload runs; once upload completes, the typing-draft gate parks at `.waitingForSendGate`; once the draft clears, send fires. Stacked sequentially without interaction. -- **Subscription teardown.** `allTypingDraftsDisposable.dispose()` in `deinit`. - -## Testing - -This codebase has no unit tests. Verification is via full build + manual exercise: - -- Build: `python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError` (prefixed with `source ~/.zshrc 2>/dev/null;`). -- Manual: in a 1:1 chat with another device, induce a live-typing draft on the peer side and confirm an outgoing text send parks (chat shows "sending" status held until draft clears or commits). Repeat for: media single send, grouped media album, forward. -- Negative manual: scheduled message — confirm not gated. Saved Messages — confirm not gated. Secret chat — confirm not gated. - -## Out of scope - -- Per-chat opt-in toggle. -- Upper-bound timeout / fallback send. -- Grace-delay after draft clears. -- UI affordance ("waiting for X to finish typing…"). -- Filtering self-authored drafts. diff --git a/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md b/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md deleted file mode 100644 index 1ee706a42a..0000000000 --- a/docs/superpowers/specs/2026-05-01-groupref-ssrc-discovery-design.md +++ /dev/null @@ -1,354 +0,0 @@ -# GroupInstanceReferenceImpl: Reactive Remote-Audio-SSRC Discovery - -**Date:** 2026-05-01 -**Status:** Approved (design only — no implementation yet) -**Scope:** `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` and adjacent test wiring. - -## Problem - -`GroupInstanceReferenceImpl` (PeerConnection-based group call client) currently learns about remote audio SSRCs **only** from a `colibriClass=ActiveAudioSsrcs` data-channel message broadcast by the test-bench Pion SFU (`tgcalls/tools/go_sfu/sfu.go`). The real Telegram SFU does not send that message — `GroupInstanceCustomImpl::receiveDataChannelMessage` only handles `SenderVideoConstraints` and `DebugMessage`. CustomImpl discovers SSRCs reactively from raw RTP via `GroupNetworkManager` → `receiveUnknownSsrcPacket` → `maybeRequestUnknownSsrc` → `_requestMediaChannelDescriptions`. ReferenceImpl has no equivalent path; in real calls every remote audio packet is silently dropped (or routed to mid=0's unsignaled handler with no application visibility). - -The fix must: -- Surface every previously-unseen remote audio SSRC to ReferenceImpl's internal logic, ideally on the first frame. -- Drive the addition of a recvonly audio transceiver for that SSRC. -- Match CustomImpl's app-facing contract — the application sees the same `_requestMediaChannelDescriptions(ssrcs, completion)` callback it already implements. -- Use a single discovery mechanism in both real and test environments (the test SFU's `ActiveAudioSsrcs` broadcast becomes obsolete and is removed; see "Removing `ActiveAudioSsrcs`" below). - -## Approach: one `GRAudioFrameTransformer` installed on every audio receiver - -WebRTC exposes `RtpReceiverInterface::SetDepacketizerToDecoderFrameTransformer(FrameTransformerInterface*)`. The transformer's `Transform(frame)` takes ownership of a `std::unique_ptr` and there is **no requirement that it call `OnTransformedFrame` synchronously** — the design is explicitly async. The transformer can hold the frame for arbitrarily long; the audio pipeline simply waits. - -We install **the same `GRAudioFrameTransformer` instance on every audio receiver** in this `GroupInstanceReferenceInternal`: - -- **mid=0 (sendrecv outgoing audio)** — explicitly via `_outgoingAudioTransceiver->receiver()->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer)` in `start()`. This lands as both the per-receiver transformer and (because mid=0's receive side has `signaled_ssrc=nullopt`) the channel's `unsignaled_frame_transformer_` — meaning any unsignaled stream created later for an unknown SSRC also gets it. -- **Each recvonly audio transceiver** (added by the discovery flow) — explicitly via the same call when the transceiver is added in `renegotiate()`. This is structurally required for the upcoming e2e-decrypt fix (every receiver needs the decrypt hook); attaching it now too is free and removes our reliance on stream-promotion implicitly carrying the transformer along. - -The transformer carries the per-SSRC state machine (described below). It also carries a `decryptHook` callable (initially null) that the e2e PR will wire to `descriptor.e2eEncryptDecrypt`. Today the hook just passes the frame through unchanged; tomorrow it decrypts before `OnTransformedFrame`. - -### Why install on every receiver explicitly - -If we relied solely on `unsignaled_frame_transformer_`, the transformer would be carried onto a recvonly transceiver only via the stream-promotion path (`webrtc_voice_engine.cc:2258-2266`: `MaybeDeregisterUnsignaledRecvStream` keeps the stream object intact, transformer attached). That's correct *today* but it's an internal-WebRTC behavior we'd be pinning to. Once we explicitly attach to each recvonly receiver we own the lifecycle: the transformer is on the stream because we put it there, regardless of how WebRTC's voice engine handles the unsignaled→signaled transition. - -For the buffer flush specifically: the buffered frames were captured while the SSRC was on mid=0's unsignaled-fallback path. When we later install the transformer on the recvonly receiver R' for the same SSRC, the channel's stream (now promoted in place) keeps the same transformer reference (same instance, same pointer) — so `releaseSsrc` flushes through `_perSsrcSinks[ssrc]` (the sink callback WebRTC registered when the stream first appeared) and frames land in that one stream's decoder. - -### Tap behavior - -For each SSRC the transformer maintains an `Entry { state, buffer, firstFrameTimeMs }` with `state ∈ { kBuffering, kDrained }`. - -`Transform(frame)` (worker thread): -1. Acquire the mutex. -2. Look up the entry for `frame->GetSsrc()`. -3. If absent and we're below `kMaxConcurrentBufferedSsrcs`: insert with `kBuffering` state, post `_onNewSsrc(ssrc)` to the media thread (outside the lock), buffer the frame. -4. If `kBuffering`: append to buffer (drop oldest if `buffer.size() >= kMaxBufferedFramesPerSsrc`). -5. If `kDrained`: release the lock, then call `OnTransformedFrame(std::move(frame))`. Live audio flows through. - -`releaseSsrc(uint32_t ssrc)` (media thread, called from `onRenegotiationComplete`): -1. Acquire the mutex. -2. Locate the entry; if absent or already `kDrained`, return. -3. Move the deque out, mark `state = kDrained`, release the mutex. -4. Iterate the moved deque calling `OnTransformedFrame(std::move(frame))` on each. Performed outside the lock to avoid re-entrant deadlocks. - -The order of operations matters: marking `kDrained` *before* releasing the lock guarantees that any concurrent `Transform()` either (a) sees `kBuffering` and buffers — but its frame is lost because we already drained the FIFO, or (b) sees `kDrained` and passes through. Case (a) is a real one-frame-loss race window. We accept it: at 20 ms Opus that's a single packet of audio, inaudible, and overwhelmingly unlikely (the window is the few microseconds between unlocking and starting the drain loop). - -### Failure mode - -If `releaseSsrc(X)` is not called within `kSsrcDiscoveryTimeoutMs = 1000` ms (renegotiation failed or app declined the SSRC), an in-line eviction inside `Transform()` drops the buffer and **leaves the entry in `kBuffering` with empty FIFO**. Subsequent frames continue to attempt to buffer (and immediately drop on the FIFO cap = 0 / re-eviction), so the participant remains silent until the entry is cleared. Acceptable — the same outcome as the pure-drop alternative would have produced. - -### Net behavior - -- **Audible continuity for new participants.** Buffered frames flush into the same stream that carries future audio. NetEQ sees the buffered burst as one large jitter-buffer fill followed by normal-paced packets — same shape as recovering from a network glitch. May produce a brief audible artifact during the burst (NetEQ may accelerate or reorder) but no silence. -- **No orphaned `AudioReceiveStream`.** Stream is promoted in place; one stream per SSRC. -- **Tap stays in the live path.** Per-frame: one mutex, one map lookup, one `OnTransformedFrame`. Hot but cheap. - -CustomImpl already uses `FrameTransformer` (for E2E encryption) — the pattern is established in this codebase. Pass-through transformers also work in WebRTC (the `OnTransformedFrame` callback is the only contract). - -### Why not the alternatives - -- **`OnTrack` for unsignaled audio:** PeerConnection's "default" handler creates one default track. Multi-SSRC behavior is murky; fragile. -- **`PeerConnection::GetStats()` polling:** Works but adds 100–250 ms of discovery latency per new participant (audio drops during the window) and the stats walk is non-trivial. -- **Tap on a custom socket factory:** Requires re-implementing SRTP decrypt and RTP parsing. Too invasive. - -## Components - -``` - ┌────────────────────────────┐ - incoming RTP (any SSRC) ───▶ │ PeerConnection BUNDLE │ - │ demuxer (SSRC-keyed) │ - └─────────────┬──────────────┘ - │ - ┌───────────────────┼─────────────────────┐ - │ │ │ - signaled SSRC X signaled SSRC Y unknown / catch-all - (recvonly mid=N1) (recvonly mid=N2) (sendrecv mid=0) - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ - │ AudioTrack │ │ AudioTrack │ │ GRSsrcTapTransformer │ - │ + level sink │ │ + level sink │ │ (Transform → notify │ - └──────────────┘ └──────────────┘ │ + OnTransformedFrame)│ - └──────────┬───────────┘ - │ (worker thread) - │ - ▼ - PostTask to media thread - │ - ▼ - handleDiscoveredAudioSsrc(ssrc) - │ - ▼ - (existing) renegotiate() + - _requestMediaChannelDescriptions -``` - -### `GRAudioFrameTransformer` (new, anonymous-namespace class in `GroupInstanceReferenceImpl.cpp`) - -```cpp -class GRAudioFrameTransformer : public webrtc::FrameTransformerInterface { -public: - using SsrcCallback = std::function; - // Hook for the future e2e-decrypt fix. Called per frame on the worker - // thread before OnTransformedFrame. Today: identity (passes the frame - // through unchanged). The e2e PR will assign it to a closure that - // unwraps the descriptor.e2eEncryptDecrypt envelope. - using DecryptHook = std::function; - - GRAudioFrameTransformer(SsrcCallback onNewSsrc, - DecryptHook decrypt, // may be nullptr - rtc::Thread* mediaThread); - - // Called from ReferenceImpl on the media thread after onRenegotiationComplete - // confirms a recvonly transceiver now owns `ssrc`. Drains the per-SSRC - // FIFO into OnTransformedFrame in arrival order; subsequent frames for - // `ssrc` flow through unchanged (kDrained = live passthrough). - void releaseSsrc(uint32_t ssrc); - - // FrameTransformerInterface - void Transform(std::unique_ptr frame) override; - void RegisterTransformedFrameCallback(rtc::scoped_refptr) override; - void RegisterTransformedFrameSinkCallback(rtc::scoped_refptr, uint32_t ssrc) override; - void UnregisterTransformedFrameCallback() override; - void UnregisterTransformedFrameSinkCallback(uint32_t ssrc) override; - -private: - enum class SsrcState { kBuffering, kDrained }; - - struct Entry { - SsrcState state = SsrcState::kBuffering; - std::deque> buffer; - int64_t firstFrameTimeMs = 0; // for timeout eviction - }; - - void evictExpired_n() RTC_EXCLUSIVE_LOCKS_REQUIRED(_mu); // called inside Transform - - SsrcCallback _onNewSsrc; - rtc::Thread* _mediaThread; // used only as identity for assertions - - webrtc::Mutex _mu; - rtc::scoped_refptr _broadcastSink RTC_GUARDED_BY(_mu); - std::map> _perSsrcSinks RTC_GUARDED_BY(_mu); - std::map _entries RTC_GUARDED_BY(_mu); - - static constexpr int64_t kSsrcDiscoveryTimeoutMs = 1000; - static constexpr size_t kMaxBufferedFramesPerSsrc = 60; // ~1.2s at 20ms Opus - static constexpr size_t kMaxConcurrentBufferedSsrcs = 64; // upper bound on memory -}; -``` - -#### `Transform` (worker thread) - -1. `ssrc = frame->GetSsrc()`. Acquire `_mu`. -2. `evictExpired_n()` — walk `_entries`; for any whose `firstFrameTimeMs` is older than `kSsrcDiscoveryTimeoutMs` and still `kBuffering`, clear the buffer (entry stays so we don't re-notify; subsequent frames re-evict and stay silent until the entry is removed by an explicit application action — acceptable failure mode). -3. Look up the entry for `ssrc`: - - **Not present and `_entries.size() >= kMaxConcurrentBufferedSsrcs`** → drop frame, no notify (overflow protection against pathological SFU behavior). - - **Not present otherwise** → insert `Entry{ kBuffering, {}, now() }`, push frame, **release lock**, invoke `_onNewSsrc(ssrc)` (which posts to media thread → `handleDiscoveredAudioSsrc(ssrc)`). - - **Present and `kBuffering`**: - - If `entry.buffer.size() >= kMaxBufferedFramesPerSsrc` → drop the oldest buffered frame (FIFO bounded). - - Push `std::move(frame)` to the back of `entry.buffer`. - - **Present and `kDrained`** → take a local copy of the appropriate sink callback (per-SSRC if registered, else broadcast), **release lock**, call `sink->OnTransformedFrame(std::move(frame))`. This is the live-audio path after `releaseSsrc` has fired. - -The mutex is held only across the lookup and entry/buffer mutation. Both branches that emit through `OnTransformedFrame` (`kDrained` in `Transform`, and `releaseSsrc`) drop the lock before the call to avoid re-entrant deadlocks — WebRTC may synchronously schedule decoder work in `OnTransformedFrame` that calls back into related machinery. - -#### `releaseSsrc(uint32_t ssrc)` (media thread) - -1. Acquire `_mu`. Locate the entry; if missing or already `kDrained`, return. -2. Find the appropriate sink callback: per-SSRC if registered, else broadcast. -3. Mark `entry.state = kDrained` and `std::move` the deque out. **Marking `kDrained` before releasing the lock** is what guarantees no concurrent `Transform()` can buffer a frame that we then fail to flush. -4. Release `_mu`. Iterate the moved deque calling `sink->OnTransformedFrame(std::move(frame))` on each. - -#### `Register*` / `Unregister*` - -Store the callbacks under `_mu`. The per-SSRC `RegisterTransformedFrameSinkCallback` is invoked by WebRTC the first time a new SSRC arrives at this transformer; we hold it so `releaseSsrc` can dispatch through it. `Unregister*` clears. - -### Hook points in `GroupInstanceReferenceInternal` - -In `start()`, after `_outgoingAudioTransceiver` is created (mid=0): - -```cpp -auto weak = std::weak_ptr(shared_from_this()); -auto threads = _threads; -_audioFrameTransformer = rtc::make_ref_counted( - /*onNewSsrc=*/[weak, threads](uint32_t ssrc) { - threads->getMediaThread()->PostTask([weak, ssrc]() { - if (auto strong = weak.lock()) { - strong->handleDiscoveredAudioSsrc(ssrc); - } - }); - }, - /*decrypt=*/nullptr, // wired in the e2e PR - /*mediaThread=*/_threads->getMediaThread()); -_outgoingAudioTransceiver->receiver()->SetDepacketizerToDecoderFrameTransformer( - _audioFrameTransformer); -``` - -In `renegotiate()`, immediately after `_peerConnection->AddTransceiver(MEDIA_TYPE_AUDIO, recvonly)` succeeds for an SSRC discovered through the tap, attach the same transformer to the new receiver: - -```cpp -auto result = _peerConnection->AddTransceiver(cricket::MEDIA_TYPE_AUDIO, init); -if (result.ok()) { - info.transceiver = result.value(); - info.transceiver->receiver() - ->SetDepacketizerToDecoderFrameTransformer(_audioFrameTransformer); -} -``` - -In `onRenegotiationComplete()`, after `wireRemoteAudioLevelSinks()` runs, release any SSRCs whose recvonly transceiver just became active: - -```cpp -if (_audioFrameTransformer) { - for (auto& [ssrc, info] : _remoteSsrcs) { - if (info.transceiver && info.transceiver->mid().has_value()) { - // Idempotent: releaseSsrc no-ops on entries already drained. - _audioFrameTransformer->releaseSsrc(ssrc); - } - } -} -``` - -### `handleDiscoveredAudioSsrc(uint32_t ssrc)` (new, on media thread) - -This is the **only** entry point for adding a remote audio SSRC. It accumulates the SSRC into `_remoteSsrcs` and **schedules** a single coalesced renegotiation rather than firing one immediately: - -```cpp -void handleDiscoveredAudioSsrc(uint32_t ssrc) { - if (ssrc == 0) return; - if (ssrc == _outgoingSsrc) return; // our own - if (_remoteSsrcs.count(ssrc) > 0) return; // already known - - std::string mid = std::to_string(_nextMid++); - RemoteSsrcInfo info; - info.mid = mid; - _remoteSsrcs.emplace(ssrc, std::move(info)); - - if (_requestMediaChannelDescriptions) { - _requestMediaChannelDescriptions({ssrc}, [](auto&&) { /* fire-and-forget */ }); - } - scheduleDiscoveryRenegotiation(); -} -``` - -### Removing `ActiveAudioSsrcs` - -With the tap as the canonical discovery path, the test SFU's `ActiveAudioSsrcs` broadcast becomes redundant — and continuing to ship it would mean test runs exercise a code path that doesn't exist in production. Three deletions: - -1. `tools/go_sfu/sfu.go` — remove the `colibriClass=ActiveAudioSsrcs` broadcast (the message construction at `sfu.go:987` and any per-participant-join trigger that emits it). Remove the `ColibriClass`/`Ssrcs` JSON struct used solely for this purpose. -2. `GroupInstanceReferenceImpl.cpp` — delete `handleActiveAudioSsrcs(json)` and the `if (colibriClass == "ActiveAudioSsrcs") { ... }` dispatch in `onDataChannelMessage`. The dispatch becomes "forward to app callback if set" only (currently forwards regardless, so just drop the colibri branch). -3. `tools/cli/group_participant.cpp` — no change required. The CLI already only reacts to `ActiveVideoSsrcs` in its `dataChannelMessageReceived`; `ActiveAudioSsrcs` was never observed by the test app, only consumed internally by ReferenceImpl. - -Verification that removal is safe: `grep -r ActiveAudioSsrcs` across the repo currently returns hits only in (1) the SFU emitter, (2) the ReferenceImpl handler we're deleting, and (3) documentation/CLAUDE.md files (which we update as part of the change). CustomImpl never references it; iOS app code never references it; the test CLI never references it. - -Removed-SSRC handling: the deleted `handleActiveAudioSsrcs` also processed *removals* (SFU told us a participant left). After deletion, ReferenceImpl no longer reacts to participant departures via the data channel. This matches CustomImpl's behavior — CustomImpl also has no remove path; SSRCs simply go silent and the application removes them from the participant list via MTProto. Recvonly transceivers stay in the SDP indefinitely, which is a small per-call leak but not a correctness issue. (If this proves to be a problem in long-running calls, a future change can add a "remove if no audio for N seconds" sweep.) - -### `scheduleDiscoveryRenegotiation()` — debounce window - -A 250 ms delayed task on the media thread coalesces a burst of discoveries into one renegotiation. The existing `renegotiate()` already iterates `_remoteSsrcs` and adds a recvonly transceiver for any entry that doesn't have one yet, so all SSRCs accumulated during the delay window are picked up in a single offer/answer cycle. - -```cpp -static constexpr int kDiscoveryRenegotiationDelayMs = 250; - -void scheduleDiscoveryRenegotiation() { - if (_discoveryRenegotiationScheduled) return; - _discoveryRenegotiationScheduled = true; - auto weak = std::weak_ptr(shared_from_this()); - _threads->getMediaThread()->PostDelayedTask( - [weak]() { - auto strong = weak.lock(); - if (!strong) return; - strong->_discoveryRenegotiationScheduled = false; - strong->renegotiate(); - }, - webrtc::TimeDelta::Millis(kDiscoveryRenegotiationDelayMs)); -} -``` - -**Layering with existing serialization.** The debounce sits on top of `renegotiate()`'s existing `_isRenegotiating` / `_pendingRenegotiation` guard. Three regimes: - -1. **No renegotiation in flight when the timer fires:** `renegotiate()` runs immediately, picks up all queued SSRCs. -2. **A renegotiation is already in flight (e.g., from `setRequestedVideoChannels`):** the queued `renegotiate()` sets `_pendingRenegotiation`, runs after the in-flight cycle completes — picks up everything including the new SSRCs. -3. **More SSRCs discovered while the timer is pending:** `_discoveryRenegotiationScheduled == true` → no new task scheduled, the SSRC just lands in `_remoteSsrcs` and joins the upcoming batch. - -Result: at most one discovery-sourced renegotiation per 250 ms, regardless of arrival burst size. - -**Audible-gap implication.** New joiners are silent during the debounce window because their packets land in mid=0's catch-all (which still decodes them and feeds the AudioMixer for playback) but their per-receiver `GRAudioLevelSink` doesn't exist yet, so the speaking-indicator UI shows no level until the renegotiation completes (~250 ms + offer/answer round-trip). Audio is heard, the indicator just lags. Acceptable. - -**Stop semantics.** On `stop()`, the queued task may still fire. The `weak_ptr` guard makes the lambda a no-op if the internal has been destroyed. The `_isRenegotiating` flag inside `renegotiate()` also bails if `_peerConnection` has been closed. - -**Behavior change in test mode:** the existing `handleActiveAudioSsrcs` calls `_requestMediaChannelDescriptions` once per batch with all new SSRCs. After refactor, it issues N single-SSRC calls. This is acceptable: requests are local fire-and-forget callbacks into the app and bear no network cost in the CLI test bench. If the iOS app's implementation later turns out to be sensitive to call frequency, `handleActiveAudioSsrcs` can re-aggregate by collecting the SSRCs first and issuing one batched request after the per-SSRC `handleDiscoveredAudioSsrc` calls — but the simpler version is the starting point. - -## Data flow - -1. SFU starts forwarding remote audio for SSRC X (no signaling messages). -2. PeerConnection demuxes the first packet for SSRC X — no recvonly transceiver matches → routed to mid=0's catch-all receiver. The voice channel creates an unsignaled `WebRtcAudioReceiveStream` for X with our tap as the depacketizer-to-decoder transformer. `Transform(frame1)` is called. -3. Tap finds no entry for X → inserts `{ kBuffering, [frame1], now() }` → posts `_onNewSsrc(X)` to the media thread → `handleDiscoveredAudioSsrc(X)`. -4. `handleDiscoveredAudioSsrc` adds X to `_remoteSsrcs`, fires `_requestMediaChannelDescriptions({X}, ...)`, calls `scheduleDiscoveryRenegotiation()` (debounce 250 ms). -5. Subsequent packets for X arrive at the tap (state still `kBuffering`) → appended to the same FIFO (oldest dropped if `>= kMaxBufferedFramesPerSsrc`). -6. After 250 ms the debounce timer fires `renegotiate()`, which adds a recvonly transceiver bound to mid=`_nextMid++` for every entry in `_remoteSsrcs` that lacks one. `SetRemoteDescription` propagates SSRC X into the recvonly m-line; `WebRtcVoiceReceiveChannel::AddRecvStream(X)` finds X in `unsignaled_recv_ssrcs_` and **promotes** the existing stream in place (no new stream created; tap transformer remains attached). -7. `onRenegotiationComplete` runs: `wireRemoteAudioLevelSinks()` attaches a `GRAudioLevelSink` to the new recvonly receiver's track; for each SSRC whose transceiver now has a mid, ReferenceImpl calls `_ssrcTapTransformer->releaseSsrc(ssrc)`. -8. `releaseSsrc(X)` marks the entry `kDrained` under the lock, moves the FIFO out, releases the lock, and calls `OnTransformedFrame` for each buffered frame in arrival order. The promoted stream's decoder receives the burst; NetEQ buffers and plays out at natural rate. -9. Subsequent packets for X reach `Transform()`; the entry is `kDrained` → tap calls `OnTransformedFrame` directly. Live audio flows. The per-receiver `GRAudioLevelSink` (attached in step 7) reads real levels from the post-decode PCM stream. - -**Failure mode (timeout).** If `releaseSsrc(X)` is not called within `kSsrcDiscoveryTimeoutMs = 1000` ms (renegotiation failed or app declined the SSRC), the in-line eviction in `Transform` clears the buffer for X. The entry stays in `kBuffering` so we don't re-notify, but no future frames are forwarded — the participant goes silent. Same outcome as the pure-drop alternative. - -## Threading - -- `Transform` runs on PeerConnection's worker thread. Hot path — must be cheap. Per call: one mutex acquisition, a deque push (or drop), an O(N_active_SSRCs) eviction walk that is cheap (typically 0–2 items per call). On first-sight SSRC the worker thread also posts to the media thread. -- `releaseSsrc` runs on the media thread (called from `onRenegotiationComplete`). Acquires the same mutex, moves the FIFO out under lock, then releases the lock and calls `OnTransformedFrame` outside the lock to avoid re-entrant deadlocks (WebRTC's `OnTransformedFrame` may call back into the transformer infrastructure). -- `OnTransformedFrame` itself: WebRTC's contract does not pin it to a specific thread; calling from the media thread is legal. WebRTC dispatches the actual depacketize/decode onto the worker thread internally. -- `handleDiscoveredAudioSsrc` runs on the media thread. Existing renegotiation machinery is media-thread-safe. -- Lifetime: the transformer is owned by `_ssrcTapTransformer` (member, `scoped_refptr`). `weak_ptr` capture in the SSRC callback prevents use-after-free during teardown. WebRTC clears the transformer when the receiver is destroyed (PeerConnection close); any frames still in the FIFO at that point are released by the deque destructor (the underlying `TransformableFrameInterface` instances are owned `unique_ptr`s and clean up automatically). - -## Testing - -After removing `ActiveAudioSsrcs`, the tap is the only discovery path in every mode — test and real. The existing CLI test bench validates it without modification: - -- All-Reference, no mute: each ReferenceImpl peer must discover the others via the tap, add recvonly transceivers, and report `level ≥ 0.05` (current invariant in `validateGroupState`). -- Mixed (CustomImpl + ReferenceImpl), no mute: same invariant from both sides. -- Mute scenarios: muted peers must still be discovered (their packets carry encoded silence; the tap fires on first packet) and their `level` must read 0 (existing muted-peer invariant from the previous fix). - -If any of these regress, the tap is broken — which is exactly the coverage we want. - -No new CLI flags or SFU exports needed; the previous draft's `--suppress-active-audio-ssrcs` is moot. - -## Out of scope (this design) - -- **E2E `e2eEncryptDecrypt` wiring** is a separate fix, but this design pre-installs the surface it needs: `GRAudioFrameTransformer::DecryptHook` is a constructor parameter (nullptr today). The e2e PR captures `descriptor.e2eEncryptDecrypt` and passes a closure that decrypts the frame's `GetData()` and writes back via `SetData()` before the transformer calls `OnTransformedFrame`. No further structural changes — the transformer is already attached to every audio receiver. -- SSRC>int31 join-payload masking (separate fix). -- Push-style discovery via a new `GroupInstanceInterface::addIncomingAudioSsrcs(...)` method. May be added later but is not needed to fix the immediate problem. -- Video SSRC discovery — handled by the existing app-facing `dataChannelMessageReceived` callback for `ActiveVideoSsrcs` (and via `setRequestedVideoChannels` from MTProto data on iOS). The same per-receiver transformer pattern would extend to incoming video for video e2e, but that's outside the audio scope here. - -## Risks - -- **Buffer-flush correctness.** The tap holds frames until `releaseSsrc` fires. If the call is missed (bug in `onRenegotiationComplete`, race with `stop()`), the timeout clears the buffer at 1 s and the participant goes silent. The CLI integration tests catch this end-to-end: with `ActiveAudioSsrcs` removed, the tap is the *only* discovery path, so the existing `receivedAudio ≥ 0.05` invariant validates the full chain. -- **Tap-passthrough correctness.** Because the tap transformer remains attached after stream promotion, *every* live frame for an SSRC also passes through `Transform()` → `kDrained` branch → `OnTransformedFrame`. If the `kDrained` branch is broken or skipped, every audio frame for every promoted SSRC is silently dropped. Same CLI test coverage applies: the moment passthrough breaks, no peer hears anyone. -- **NetEQ jitter-buffer burst on flush.** `releaseSsrc` flushes up to ~1 s of audio (`kMaxBufferedFramesPerSsrc` × 20 ms = 1.2 s) into the receive stream in one tight loop. NetEQ sees this as a jitter-buffer fill spike. It will normally play out at the natural 50 fps rate, but the burst may exceed `audio_jitter_buffer_max_packets_` and trigger acceleration, deletion, or PLC artifacts. Worst case: a brief audio glitch when a new participant first speaks. Acceptable; mitigated by setting `kMaxBufferedFramesPerSsrc` aggressively low (e.g., 30 frames = 600 ms) if the artifact proves audible. -- **Race window during release.** Marking `kDrained` while still holding the lock prevents concurrent `Transform()` from buffering a doomed frame. There is still a microsecond between unlock and the start of the drain loop where a `Transform()` could beat `releaseSsrc` to the live-passthrough path — but the result is just the new frame arriving at the decoder *before* the buffered backlog. NetEQ reorders by RTP timestamp; harmless. -- **Renegotiation storm.** Mitigated by the 250 ms debounce in `scheduleDiscoveryRenegotiation()`: a burst of N SSRCs in the window collapses to one renegotiation. The existing `_isRenegotiating` / `_pendingRenegotiation` flags handle the case where another renegotiation source (e.g., `setRequestedVideoChannels`) is concurrently in flight. The first-sight check in the tap prevents duplicate per-SSRC scheduling. -- **Memory-pressure cap.** Worst-case buffered audio: `kMaxConcurrentBufferedSsrcs` × `kMaxBufferedFramesPerSsrc` × ~80 bytes/frame ≈ 64 × 60 × 80 = 308 KB. Both bounds are deliberately conservative — a misbehaving SFU sending unique SSRCs per packet hits the SSRC cap and drops further new ones rather than allocating unbounded memory. -- **Stream promotion is still load-bearing for buffered audio.** Live frames flow correctly because we explicitly install the transformer on each recvonly receiver — that path is no longer dependent on internal WebRTC behavior. *Buffered frames* still rely on the unsignaled→signaled stream promotion: the per-SSRC `TransformedFrameCallback` we got at first-sight is the one we replay through. If a future WebRTC update breaks promotion (constructs a new signaled stream and orphans the unsignaled one), `releaseSsrc` would dispatch frames into the orphan and they'd never decode. The CLI tests catch this — buffered audio would be silent during the first 250–500 ms of every new participant, which the audio-level invariant will flag in mute-style tests if extended to assert on first-second levels. - -## Files touched (anticipated) - -- `submodules/TgVoipWebrtc/tgcalls/tgcalls/group/GroupInstanceReferenceImpl.cpp` — add `GRAudioFrameTransformer` (per-SSRC FIFO + `releaseSsrc` + `decryptHook` callable initialized to nullptr); construct + install on mid=0's receiver in `start()`; install on each new recvonly receiver in `renegotiate()`; add `handleDiscoveredAudioSsrc` and `scheduleDiscoveryRenegotiation`; **delete `handleActiveAudioSsrcs` and its dispatch in `onDataChannelMessage`**; call `releaseSsrc` from `onRenegotiationComplete` for each newly-mid-assigned SSRC; add `_audioFrameTransformer` (`scoped_refptr`) and `_discoveryRenegotiationScheduled` members. -- `submodules/TgVoipWebrtc/tgcalls/tools/go_sfu/sfu.go` — **delete `ActiveAudioSsrcs` broadcast** (message construction at `sfu.go:987` and any per-join trigger). -- `submodules/TgVoipWebrtc/CLAUDE.md` and `submodules/TgVoipWebrtc/tgcalls/tools/cli/CLAUDE.md` — update to reflect that ReferenceImpl discovers SSRCs via a `FrameTransformer` tap on mid=0; remove `ActiveAudioSsrcs`/`SetDefaultRawAudioSink` references. -- No iOS-side changes (`OngoingCallThreadLocalContext.mm` etc.) — the existing `requestMediaChannelDescriptions` callback already supports the discovery path. -- No CLI changes (`tools/cli/main.cpp`, `group_mode.cpp/.h`) — the existing tests validate the tap directly. diff --git a/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md b/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md deleted file mode 100644 index cd87db6704..0000000000 --- a/docs/superpowers/specs/2026-05-01-instant-page-underline-rendering-design.md +++ /dev/null @@ -1,101 +0,0 @@ -# InstantPage underline rendering - -## Problem - -`layoutTextItemWithString` in `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` does not handle the `NSAttributedString.Key.underlineStyle` attribute. Underline runs are produced upstream by `InstantPageTextStyleStack.textAttributes()` (`InstantPageTextStyleStack.swift:194-202`) for two distinct sources: - -1. Explicit `RichText.underline` runs (push at `InstantPageTextItem.swift:607`, plus `:628` and `:657` for related cases). -2. Links whose computed foreground color matches the body-text color — the styleStack falls back to underlining them so they remain distinguishable (`InstantPageTextStyleStack.swift:200-201`). - -The attribute lands on the attributed string in both cases, but the per-line attribute enumerator at `InstantPageTextItem.swift:915-938` only branches on `strikethroughStyle`, `InstantPageMarkerColorAttribute`, and `InstantPageAnchorAttribute`. Underline runs are silently dropped during layout, so they never get drawn. - -The canonical handling pattern lives in `submodules/Display/Source/TextNode.swift:2061-2066` (collection during layout) and `:2619-2638` (manual draw at draw time). `TextNode` deliberately draws underlines manually (`drawUnderlinesManually = true` at `:216`) rather than letting Core Text render them, because CT's underline rendering has historic positioning, color, and clipping issues across glyph clusters and emoji. - -## Goal - -Render underlines in InstantPage articles wherever the styleStack emits `underlineStyle`, matching `TextNode.swift` line-for-line so a future reader sees the same shape in both files. - -## Non-goals - -- Wavy or double underline support — the InstantPage styleStack only emits `NSUnderlineStyle.single`. -- Changes to `InstantPageTextStyleStack` — the attribute it produces is already correct. -- Changes to the existing strikethrough draw's reliance on the context's residual fill color — out of scope, not regressed. -- Changes to `attributesAtPoint` or selection-rect logic — these read attributes directly off `attributedString`, so they already work for underlined ranges. - -## Design - -### New type - -In `InstantPageTextItem.swift`, alongside `InstantPageTextStrikethroughItem`: - -```swift -struct InstantPageTextUnderlineItem { - let frame: CGRect - let range: NSRange - let color: UIColor? -} -``` - -`color` carries an optional `NSAttributedString.Key.underlineColor`. There is no `style` field — `.single` is the only value the styleStack emits. `range` is needed at draw time to look up `foregroundColor` per-range when `underlineColor` is absent. - -### Line storage - -Add `let underlineItems: [InstantPageTextUnderlineItem]` to `InstantPageTextLine`, with a matching init parameter alongside `strikethroughItems`. There is exactly one construction site for `InstantPageTextLine` (`InstantPageTextItem.swift:970`), so updating the initializer is local. - -### Collection (layoutTextItemWithString) - -In the `enumerateAttributes` loop currently at `InstantPageTextItem.swift:915-938`, add a parallel branch that mirrors `TextNode.swift:2061-2066`: - -```swift -if let _ = attributes[NSAttributedString.Key.underlineStyle] { - let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) - let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) - let x = lowerX < upperX ? lowerX : upperX - underlineItems.append(InstantPageTextUnderlineItem( - frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight), - range: range, - color: attributes[NSAttributedString.Key.underlineColor] as? UIColor - )) -} -``` - -Geometry is verbatim the strikethrough branch's — same `lowerX`/`upperX` clamp and the same `workingLineOrigin.x + x` offset. The collection is independent of strikethrough; both branches can fire on the same range. - -### Draw (drawInTile) - -After the strikethrough draw block at `InstantPageTextItem.swift:261-266`, add: - -```swift -if !line.underlineItems.isEmpty { - for item in line.underlineItems { - var color: UIColor? = item.color - if color == nil { - self.attributedString.enumerateAttributes(in: item.range, options: []) { attributes, _, _ in - if let foreground = attributes[NSAttributedString.Key.foregroundColor] as? UIColor { - color = foreground - } - } - } - if let color { - context.setFillColor(color.cgColor) - } - let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0) - context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + 1.0, width: itemFrame.size.width, height: 1.0)) - } -} -``` - -Color resolution order (`underlineColor` → per-range `foregroundColor`) and position rule (`y: minY + 1.0`, `height: 1.0`) match `TextNode.swift:2624-2638` exactly. - -The `setFillColor` call is gated on a non-nil resolved color so we do not silently flip an unrelated drawing's fill color if no foreground attribute is found in the range. In practice the attributed string always carries a `foregroundColor` (set unconditionally by the styleStack at `InstantPageTextStyleStack.swift:198-211`), so the gate is defense-in-depth, not a hot path. - -## Verification - -- Full Bazel build via `Make.py … --configuration=debug_sim_arm64`. -- Manual smoke against an article whose body contains an explicit `` block, and a separate article whose link color matches the body color (the styleStack's link-fallback case). - -No unit tests exist in this project (per `CLAUDE.md`). - -## Risk - -Additive: a new struct, a new optional field on `InstantPageTextLine`, one new branch in the layout enumerator, one new draw block. No public API changes, no signature changes outside `InstantPageTextItem.swift`. Runs without `underlineStyle` are unaffected. diff --git a/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md b/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md deleted file mode 100644 index 5e946f94ac..0000000000 --- a/docs/superpowers/specs/2026-05-01-rich-bubble-instant-page-link-handling-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Instant-page link handling in `ChatMessageRichDataBubbleContentNode` - -## Context - -`ChatMessageRichDataBubbleContentNode` renders a webpage's `instantPage` inline inside a chat bubble, by reusing the same `InstantPageLayout`/`InstantPageTile`/`InstantPageNode` machinery the full-screen instant view uses. Today the layout, tiles, and item nodes are wired up correctly, but every interactive callback (`openUrl`, `openPeer`, `openMedia`, …) on the realized item nodes is a commented stub, and `tapActionAtPoint` always returns `.none`. As a result, taps on URLs inside the inline preview do nothing. - -The full-screen instant view (`submodules/InstantPageUI/Sources/InstantPageControllerNode.swift`) handles URL taps by walking the layout to find the `InstantPageTextItem` under the tap location, asking it for `urlAttribute(at:)`, and then routing the resulting `InstantPageUrlItem` through its own `openUrl(_:)` resolver. Same-page anchors are handled inline via `scrollToAnchor(_:)`. - -Chat text bubbles handle URL taps via `ChatMessageBubbleContentTapAction(content: .url(...), rects:, activate:)`. The `activate` closure returns a `Promise` driven by upstream URL resolution; while it's `true` the bubble shows a `LinkHighlightingNode` overlay so users get press-feedback. - -## Goal - -Wire URL tap handling and link-highlight feedback into `ChatMessageRichDataBubbleContentNode`, plus stubbed handlers for intra-page anchor scrolling. Item-level `openUrl`/`openPeer` callbacks emitted by realized `InstantPageNode`s also route to the chat's `controllerInteraction`. - -Out of scope: media taps, pinch preview, embed height updates, details expansion, long-press action-sheet (Open / Copy / Add to Reading List), and the actual implementation of intra-page anchor scrolling — these stay as no-op stubs and can land as follow-ups. - -## Design - -### File touched - -`submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` (only). - -### New private state - -``` -private var linkProgressDisposable: Disposable? -private var linkProgressRects: [CGRect]? -private var linkHighlightingNode: LinkHighlightingNode? -``` - -`deinit` disposes `linkProgressDisposable`. - -### Tap detection - -Two private helpers, modelled on `InstantPageControllerNode`: - -``` -private func textItemAtLocation(_ point: CGPoint) -> (InstantPageTextItem, CGPoint)? -private func urlForTapLocation(_ point: CGPoint) - -> (item: InstantPageTextItem, urlItem: InstantPageUrlItem, localPoint: CGPoint)? -``` - -- The incoming `point` is in the bubble-content-node coordinate system. The helpers subtract the `containerNode` offset `(1.0, 1.0)` once on entry, then walk `currentPageLayout?.layout.items`. -- Top-level `InstantPageTextItem`s, `InstantPageScrollableItem` (delegates to its own `textItemAtLocation` accounting for content offset), and `InstantPageDetailsItem` (looks up the realized `InstantPageDetailsNode` via `visibleItemsWithNodes` and queries its nested layout) are all supported — same coverage as the IV. -- `urlForTapLocation` calls `item.urlAttribute(at:)`. Returns the matched item, the `InstantPageUrlItem`, and the item-local point. The local point is what `linkSelectionRects(at:)` consumes when computing highlight rects. - -### `tapActionAtPoint` body - -Skeleton (existing `messageOptions` early-return is preserved): - -``` -override public func tapActionAtPoint(...) -> ChatMessageBubbleContentTapAction { - if case .tap = gesture { - } else { - if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { - return ChatMessageBubbleContentTapAction(content: .none) - } - } - - guard let urlHit = self.urlForTapLocation(point) else { - return ChatMessageBubbleContentTapAction(content: .none) - } - - let (baseUrl, anchor) = splitAnchor(urlHit.urlItem.url) - if let webpage = self.currentLoadedWebpage(), webpage.url == baseUrl, let anchor { - return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in - self?.scrollToAnchor(anchor) - })) - } - - let concealed = true // see "Concealed flag" note below - let url = ChatMessageBubbleContentTapAction.Url(url: urlHit.urlItem.url, concealed: concealed) - let rects = self.computeHighlightRects(item: urlHit.item, localPoint: urlHit.localPoint) - return ChatMessageBubbleContentTapAction( - content: .url(url), - rects: rects, - activate: self.makeActivate(item: urlHit.item, localPoint: urlHit.localPoint) - ) -} -``` - -**Concealed flag**: default to `concealed = true` for v1. Reason: `InstantPageTextItem` does not expose a clean "attribute substring with range" API the way the chat text node does, so we cannot easily compare displayed link text to its target URL. `true` is the safer (more disclosure) default — chat will show a confirmation if the visible text and resolved URL differ. If during implementation a clean substring path emerges, switch to `doesUrlMatchText(url:text:fullText:)` analogously to text-bubble. - -### Highlight feedback - -`makeActivate(item:localPoint:)` mirrors the text-bubble pattern: - -``` -private func makeActivate(item: InstantPageTextItem, localPoint: CGPoint) -> (() -> Promise?)? { - return { [weak self] in - guard let self else { return nil } - let promise = Promise() - self.linkProgressDisposable?.dispose() - if self.linkProgressRects != nil { - self.linkProgressRects = nil - self.updateLinkProgressState() - } - self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in - guard let self else { return } - let updated: [CGRect]? = value - ? self.computeHighlightRects(item: item, localPoint: localPoint) - : nil - if self.linkProgressRects != updated { - self.linkProgressRects = updated - self.updateLinkProgressState() - } - }) - return promise - } -} -``` - -`computeHighlightRects(item:localPoint:)`: -- Calls `item.linkSelectionRects(at: localPoint)` — already public on `InstantPageTextItem`, returns the URL run's line rects in item-local coords. -- Translates each rect into `containerNode`-local coords by adding `item.frame.origin` plus any parent offset captured at hit-test time (zero for top-level items; the offset returned by `textItemAtLocation` for items nested under scrollables/details). - -`updateLinkProgressState()`: -- If `linkProgressRects` is non-nil and non-empty: lazily create `linkHighlightingNode` (`LinkHighlightingNode(color: incoming-or-outgoing linkHighlightColor)` derived from `self.item?.message.effectivelyIncoming(...)`), inserted into `containerNode` at index 0 (below all tiles). Set its frame to `containerNode.bounds`. Call `updateRects(rects)`. -- Otherwise: fade out the existing `linkHighlightingNode` (alpha 1→0 over 0.18s, remove on completion) and clear the field. - -Insertion order: rich-bubble tiles use `backgroundColor: .clear`, so a highlighting node positioned below them is visible through. Tiles are added with `insertSubnode(_, at: 0)` / `aboveSubnode:` — inserting the highlight at index 0 keeps it underneath every tile but inside the same `containerNode` clip region. - -### Item-callback wiring (inside `item.node(...)`) - -The currently stubbed callbacks become: - -``` -openMedia: { _ in /* TODO */ }, -longPressMedia: { _ in /* TODO */ }, -activatePinchPreview: { _ in /* TODO */ }, -pinchPreviewFinished: { _ in /* TODO */ }, -openPeer: { [weak self] peer in - guard let self, let item = self.item else { return } - item.controllerInteraction.openPeer(peer, .chat(textInputState: nil, subject: nil, peekData: nil), nil, .default) -}, -openUrl: { [weak self] urlItem in - guard let self, let item = self.item else { return } - let (baseUrl, anchor) = splitAnchor(urlItem.url) - if let webpage = self.currentLoadedWebpage(), webpage.url == baseUrl, let anchor { - self.scrollToAnchor(anchor) - return - } - item.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl( - url: urlItem.url, - concealed: false, - message: item.message, - allowInlineWebpageResolution: urlItem.webpageId != nil - )) -}, -updateWebEmbedHeight: { _ in }, -updateDetailsExpanded: { _ in }, -``` - -- `openPeer` matches the IV's default routing — open the chat for the peer. -- `openUrl` honors the same-page-anchor stub, so item-emitted URL taps share the placeholder hook with text-tap routing. -- `urlItem.webpageId != nil` is mapped to `allowInlineWebpageResolution`. `InstantPageUrlItem.webpageId` is the IV's hint that the URL was authored as a referenced webpage, which is the same intent the chat flag captures. - -### Helpers - -``` -private func splitAnchor(_ url: String) -> (base: String, anchor: String?) -private func currentLoadedWebpage() -> TelegramMediaWebpageLoadedContent? -private func scrollToAnchor(_ anchor: String) { - // TODO: implement intra-page anchor scrolling -} -``` - -`splitAnchor` extracts the `#fragment` from a URL using the same approach as `InstantPageControllerNode.openUrl` (find `#`, percent-decode the suffix, slice the prefix). `currentLoadedWebpage` pulls `case .Loaded(content)` out of the first `TelegramMediaWebpage` on `self.item?.message.media`. - -## Verification - -- Build the app with `python3 build-system/Make/Make.py … build … --configuration=debug_sim_arm64` (no unit tests in this project). -- Manual test: send a message containing a t.me link with an instant-view preview. Tap a URL inside the rich-data preview bubble — it should route to the chat's URL handler (open inline webview / external browser / peer chat as appropriate). Long-press should fall through to the existing chat URL long-press menu (the bubble framework provides this for `.url` taps with `hasLongTapAction: true`, the default). Tapping a same-page anchor in the preview should hit the empty `scrollToAnchor` stub (no-op for now). -- Visual: while a URL is resolving, the URL run should be highlighted with a `LinkHighlightingNode` rectangle in the bubble's link-highlight color. The highlight should fade out on completion or cancellation. - -## Open follow-ups (not in this spec) - -- Implement `scrollToAnchor` (likely "open the full instant view at this anchor", since the inline rich bubble has no scroll view). -- Wire `openMedia` / `longPressMedia` / `activatePinchPreview` / `updateDetailsExpanded` / `updateWebEmbedHeight`. -- Long-press action sheet (Open / Copy / Add to Reading List) for URLs inside the inline preview, mirroring the IV. diff --git a/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md b/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md deleted file mode 100644 index 0dbdf584e9..0000000000 --- a/docs/superpowers/specs/2026-05-01-rich-bubble-text-selection-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# Text selection in `ChatMessageRichDataBubbleContentNode` - -## Context - -`ChatMessageRichDataBubbleContentNode` renders an instant-page preview inline inside a chat bubble using `InstantPageLayout`/`InstantPageTile`/`InstantPageNode`. Users can already tap URLs and tap media (gallery), but cannot select any of the article text inside the preview. - -Two reference implementations exist: - -- `ChatMessageTextBubbleContentNode` (`submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/...`) — uses `TextSelectionNode` (drag-handle / knob style, action menu via `controllerInteraction.performTextSelectionAction`). Selection is gated on the bubble entering context-preview mode (`updateIsExtractedToContextPreview(true)`). The text-bubble has a single `textNode` so `TextSelectionNode` wraps it directly. -- `InstantPageControllerNode` (`submodules/InstantPageUI/Sources/...`) — paragraph-granularity highlight + `ContextMenuController`. Long-tap on a paragraph selects the whole paragraph; no drag-handles. - -The user picked the chat text-bubble model, gated only on context-preview mode. The structural challenge: rich-bubble has **many** `InstantPageTextItem`s spread across tiles, with no per-item rendering node — text is drawn directly into tile contexts via `CTLine`. - -## Goal - -Wire drag-handle text selection inside `ChatMessageRichDataBubbleContentNode`, available only in context-preview mode, with cross-paragraph selection across all `InstantPageTextItem`s in the visible layout. Action menu integrates Copy / Translate / Share / Speak / Look Up via `controllerInteraction.performTextSelectionAction`. Quote is disabled (the IV preview text is not part of `item.message.text`, which the quote feature references). - -Out of scope: nested selection inside `InstantPageDetailsItem` / `InstantPageScrollableItem` (rich-bubble does not expand details or scroll inner content); selection during normal (non-preview) interaction; per-paragraph selection-only mode. - -## Design - -### Files touched - -- **Modify:** `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` — promote three accessors to public so a `TextNodeProtocol` adapter can build on top. -- **Create:** `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` — a `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view. -- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD` — add `//submodules/TextSelectionNode`. -- **Modify:** `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` — add the `import`, two new ivars, and lifecycle hooks for entering / leaving context preview. - -### API exposure on `InstantPageTextItem` - -Three accessors become public: - -```swift -public final class InstantPageTextItem: InstantPageItem { - public let attributedString: NSAttributedString // was `let` (package-private) - // ... - - public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? - public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? -} -``` - -- The new `attributesAtPoint(_:orNearest:)` extends the existing internal `attributesAtPoint(_:)`. When `orNearest == true` and no line directly contains the point, it picks the line with the smallest vertical distance to the point and runs `CTLineGetStringIndexForPosition` with the X clamped to that line's horizontal range. Mirrors what `TextNode.attributesAtPoint(orNearest:)` does. The existing internal `attributesAtPoint(_:)` is preserved (still used by `urlAttribute(at:)` and `linkSelectionRects(at:)`). -- The new `textRangeRects(in:)` wraps the existing internal `rangeRects(in:)`. It returns `Display.TextRangeRectEdge` (same `(x, y, height: CGFloat)` shape as the IV's local `InstantPageTextRangeRectEdge`). When the inner result has no edges (range maps to no rects), the public version returns `nil`. - -The existing internal members are not renamed — only new public surface is added. - -### `InstantPageMultiTextAdapter` - -A `TextNodeProtocol`-conforming `ASDisplayNode` that aggregates multiple `InstantPageTextItem`s into a single character-indexed text view: - -```swift -public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol { - private struct Entry { - let item: InstantPageTextItem - let charOffset: Int // global char index where this item's text starts - let frameOrigin: CGPoint // item.frame.origin, in adapter-local coords - } - - private let entries: [Entry] - private let combinedString: NSAttributedString - - public init(items: [InstantPageTextItem]) - - // TextNodeProtocol - public var currentText: NSAttributedString? { combinedString } - public func attributesAtPoint(_ point: CGPoint, orNearest: Bool) -> (Int, [NSAttributedString.Key: Any])? - public func textRangeRects(in range: NSRange) -> (rects: [CGRect], start: TextRangeRectEdge, end: TextRangeRectEdge)? -} -``` - -**Construction.** `init(items:)` walks the list in document order. For each item: -1. Append `item.attributedString` to the running combined string. -2. Record `Entry(item, charOffset: combined.length-before-append, frameOrigin: item.frame.origin)`. -3. Append `"\n\n"` (plain) as a separator between entries (no separator after the last). - -The separator chars sit in the global string with no rects in `textRangeRects` (no entry contains them), so visual selection cleanly skips inter-paragraph gaps. They also keep paragraph breaks in the copied text. - -**`attributesAtPoint(_:orNearest:)`.** -1. Direct pass: for each entry, compute `localPoint = point - entry.frameOrigin`. If `entry.item.attributesAtPoint(localPoint, orNearest: false)` returns a hit, return `(entry.charOffset + localIndex, attrs)`. -2. Fallback (only when `orNearest == true`): pick the entry whose frame has the smallest vertical distance to `point.y` (zero if `point.y` is in the y-range, otherwise `min(|p.y - frame.minY|, |p.y - frame.maxY|)`), then call its `attributesAtPoint(localPoint, orNearest: true)`. Return `(entry.charOffset + localIndex, attrs)` or `nil` if even the nearest item returns nil. -3. Otherwise return `nil`. - -**`textRangeRects(in:)`.** Splits the global range across entries: -1. For each entry whose `[charOffset, charOffset + item.attributedString.length)` intersects the requested range: - - Compute the local sub-range within the entry. - - Call `entry.item.textRangeRects(in: localRange)`. - - Translate each rect by `entry.frameOrigin`. - - First contributing entry: take its `start` edge translated by `frameOrigin`. - - Each contributing entry updates `end` to its translated `end` edge. -2. If no entry contributed any rects, return `nil`. -3. Otherwise return `(allRects, start, end)`. - -The adapter is invisible — it has no contents and is purely a `TextNodeProtocol` provider. It exists as an `ASDisplayNode` only because the protocol requires it. - -### Rich-bubble lifecycle wiring - -**New ivars:** - -```swift -private var textSelectionAdapter: InstantPageMultiTextAdapter? -private var textSelectionNode: TextSelectionNode? -``` - -**Imports / BUILD:** add `import TextSelectionNode` and `//submodules/TextSelectionNode` to the rich-bubble's BUILD deps. `TextRangeRectEdge` lives in `Display`, already imported. - -**Entering preview** (`updateIsExtractedToContextPreview(true)`): -1. Bail out if `textSelectionNode != nil`, no `item`, no `currentPageLayout`, or no `chatControllerNode`. -2. Filter `currentPageLayout.layout.items` to selectable, non-empty `InstantPageTextItem`s. -3. Construct `InstantPageMultiTextAdapter(items:)`, set its frame to `containerNode.bounds`, add it to `containerNode`. -4. Pick incoming/outgoing `textSelectionColor` and `textSelectionKnobColor` from the presentation theme. -5. Construct `TextSelectionNode` with: - - `textNodeOrView: .node(adapter)` - - `present`: routes to `controllerInteraction.presentControllerInCurrent` when `item.associatedData.subject` matches `.messageOptions(_, _, info)` with `case .reply = info`, else `presentGlobalOverlayController` — same branch the text-bubble uses (see `ChatMessageTextBubbleContentNode.swift:1651-1654`). - - `rootView`: returns the `chatControllerNode` view. - - `performAction`: routes to `controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)`. -6. Set flags: - - `enableCopy = (!associatedData.isCopyProtectionEnabled && !message.isCopyProtected()) || message.id.peerId.isVerificationCodes` — same rule as text-bubble. - - `enableQuote = false` — quote-replies reference `item.message.text`; IV preview text isn't in there. - - `enableTranslate = true`, `enableShare = true`, `enableLookup = true`. -7. Set `textSelectionNode.frame` and `textSelectionNode.highlightAreaNode.frame` to `containerNode.bounds`. -8. Add `highlightAreaNode` then `textSelectionNode` to `containerNode`. - -**Leaving preview** (`willUpdateIsExtractedToContextPreview(false)`): mirror text-bubble's tear-down — animate alpha 1→0 over 0.2s on both `highlightAreaNode` and the `textSelectionNode` itself, remove from supernode in the completion, clear both ivars synchronously so a subsequent re-entry creates fresh nodes. - -**Subnode ordering** inside `containerNode` (bottom → top): tiles → `linkHighlightingNode` (touch state, from earlier task) → `linkProgressView` (in-flight URL shimmer) → adapter (invisible) → `textSelectionNode.highlightAreaNode` → `textSelectionNode`. Order preserved by insertion sequence. - -**Coordinate strategy.** Both adapter and `textSelectionNode` use `containerNode.bounds`. The IV layout origin is `(0, 0)` inside `containerNode`, and `InstantPageTextItem.frame` is in that space — so the adapter's local coords line up with item frames directly without a `(1, 1)` translation. (The `(1, 1)` translation only applies to points coming from the bubble-content-node coord space, e.g., in `tapActionAtPoint`.) - -## Verification - -- Build green: `python3 build-system/Make/Make.py … build … --configuration=debug_sim_arm64`. No automated tests in this project. -- Manual test in simulator: - 1. Find or send a message with a rich-data IV preview (`debugRichText` setting must be on). - 2. Long-press the bubble — it lifts into the context-preview popover, the message context menu appears alongside. - 3. In the lifted preview, select text by tapping and dragging knobs. Selection extends across paragraphs. - 4. Action menu (Copy / Translate / Share / Speak / Look Up) appears anchored on the selection. Confirm Copy puts the selected text on the pasteboard, with `\n\n` between paragraphs. - 5. Quote menu item is **not** present. - 6. Dismiss the context preview — selection overlay and knobs fade out cleanly. - -## Open follow-ups (not in this spec) - -- Cross-paragraph selection inside expanded `InstantPageDetailsItem` / scrollable `InstantPageScrollableItem` content (rich-bubble doesn't currently expand or scroll those). -- Spoiler awareness on selection (text-bubble has spoiler-aware selection that triggers reveal). IV text items currently don't carry spoiler attributes through `attributedString` in a way that's symmetric with chat text, so deferred. -- Search-text highlighting within the IV preview (`updateSearchTextHighlightState`). diff --git a/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md b/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md deleted file mode 100644 index bdd859d8a0..0000000000 --- a/docs/superpowers/specs/2026-05-01-rich-data-bubble-scroll-to-anchor-design.md +++ /dev/null @@ -1,149 +0,0 @@ -# ChatMessageRichDataBubbleContentNode.scrollToAnchor - -## Background - -`ChatMessageRichDataBubbleContentNode` renders a webpage's `instantPage` inline inside a chat message bubble (the same layout/tile machinery as `InstantPageControllerNode`, but embedded as a content node of `ChatMessageBubbleItemNode`). - -The bubble already detects in-page anchor links (URL with a `#fragment`) when its base URL matches the current loaded webpage and routes them to a private `scrollToAnchor(_ anchor: String)`. That method is a stub today: - -```swift -private func scrollToAnchor(_ anchor: String) { - guard let item = self.item else { return } - item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) -} -``` - -`ChatHistoryListNode.scrollToMessage(index:offset:)` ignores the offset, so the anchor name is dropped and the bubble simply scrolls to the message top. Tapping a footnote / section link inside a long instant-page bubble does nothing useful when the target is below the fold. - -## Goal - -When an in-page anchor inside a rich-data bubble is tapped, scroll the chat history so the anchor's line lands at the top of the visible content area. - -## Non-goals (explicitly deferred) - -- **Reference popup**: `InstantPageControllerNode.scrollToAnchor` shows `InstantPageReferenceController` as an overlay when the anchor is a footnote-style "reference" (text item, non-empty anchor text). We will simply scroll to the line containing the reference instead. No popup. -- **Collapsed details expansion**: The bubble already no-ops `updateDetailsExpanded`, so the runtime never toggles `InstantPageDetailsItem` state. We compute the rect for anchors inside details items as if they were expanded; no expansion side-effect is performed. Worst case for a layout-collapsed details anchor is a slightly-off scroll target — acceptable for v1. - -## Approach - -Add a `getAnchorRect(anchor:)` resolver on the bubble (mirrors `getQuoteRect`'s shape: base no-op, rich-data override walks the layout, bubble item forwards to content nodes). The chat controller then uses `forEachVisibleItemNode` to find the bubble being scrolled to (it is by definition partially visible — the user tapped a link in it), reads the anchor's item-local y, and dispatches `historyNode.scrollToMessage(... scrollPosition: .bottom(anchorY))`. `.bottom(additionalOffset)` places the item so its frame.maxY lands at `(visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (item-local-y of the anchor's top edge), the anchor renders at the visual top of the chat's content area regardless of whether the item is short or tall. (`.center(.custom)` was the original pick but is bypassed for items that fit in the content area, and the rotation maps "list-coord low" to "visual bottom" in chat lists, so `.bottom` is the more uniform primitive here.) - -### Components - -#### 1. `ChatMessageBubbleContentNode.getAnchorRect(anchor:)` — base, default `nil` - -Add an `open func getAnchorRect(anchor: String) -> CGRect? { return nil }` to the base class so callers don't need to type-test every content node. - -#### 2. `ChatMessageRichDataBubbleContentNode.getAnchorRect(anchor:)` — override - -Walk `self.currentPageLayout?.layout.items`, mirroring the cases in `InstantPageControllerNode.findAnchorItem`: -- `InstantPageAnchorItem` with matching `anchor` → return a 1pt rect at the item's `frame.origin`. -- `InstantPageTextItem`, `item.anchors[anchor] == (lineIndex, _)` → return the rect of `item.lines[lineIndex].frame`, offset by `item.frame.origin`. -- `InstantPageTableItem`, `item.anchors[anchor] == (offset, _)` → return a 1pt-tall row-width rect at `item.frame.origin + (0, offset)`. -- `InstantPageDetailsItem` → recurse into `item.items` with `baseY` increased by `item.frame.minY + item.titleHeight` (inner items live below the title bar; mirrors `InstantPageDetailsNode.linkSelectionRects`). Per non-goal #2, no expand side-effect. - -The walk returns coordinates in *layout space* (= `containerNode`-local). The bubble's `containerNode` is offset `(1, 1)` from the bubble content node, so add `(1, 1)` before returning. The returned rect is in `ChatMessageRichDataBubbleContentNode`'s own coordinate space (its `view`). - -If no anchor matches anywhere in the tree, return `nil`. - -#### 3. `ChatMessageBubbleItemNode.getAnchorRect(anchor:)` — public - -Add next to the existing `getQuoteRect(quote:offset:)`. Iterate `self.contentNodes`; for each, call `contentNode.getAnchorRect(anchor:)` and, if non-nil, return `contentNode.view.convert(rect, to: self.view)`. Return `nil` if no content node knows the anchor. - -#### 4. `ChatControllerInteraction.scrollToMessageIdWithAnchor` — new closure - -Add a new public closure on `ChatControllerInteraction`: - -```swift -public let scrollToMessageIdWithAnchor: (MessageIndex, String) -> Void -``` - -Wire through the initializer (parameter, assignment) alongside the existing `scrollToMessageId`. The existing `scrollToMessageId(MessageIndex, CGFloat)` closure stays untouched — its 7 callers (incl. 6 no-op stubs) need no signature change. - -Add no-op stubs `scrollToMessageIdWithAnchor: { _, _ in }` at the six existing no-op sites: -- `BrowserUI/Sources/BrowserBookmarksScreen.swift` -- `Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsControllerNode.swift` -- `Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift` -- `Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift` -- `TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift` -- `TelegramUI/Sources/SharedAccountContext.swift` - -#### 5. Real implementation in `ChatController.swift` - -Next to the existing `scrollToMessageId:` argument in the `ChatControllerInteraction(...)` construction, add: - -```swift -scrollToMessageIdWithAnchor: { [weak self] index, anchor in - guard let self else { return } - var anchorY: CGFloat? - self.chatDisplayNode.historyNode.forEachVisibleItemNode { itemNode in - guard anchorY == nil else { return } - if let itemNode = itemNode as? ChatMessageBubbleItemNode, - itemNode.item?.message.id == index.id, - let rect = itemNode.getAnchorRect(anchor: anchor) { - anchorY = rect.minY - } - } - if let anchorY { - self.chatDisplayNode.historyNode.scrollToMessage( - from: index, to: index, - animated: true, highlight: false, - scrollPosition: .bottom(anchorY) - ) - } else { - self.chatDisplayNode.historyNode.scrollToMessage(index: index) - } -} -``` - -`ChatHistoryListNode.scrollToMessage(from:to:animated:highlight:quote:subject:scrollPosition:setupReply:)` already accepts `scrollPosition` and routes it through `MessageHistoryScrollToSubject` → `ListViewScrollToItem.position`. The `.bottom(additionalOffset)` formula sets `frame.maxY' = (visibleSize.height - insets.bottom) + additionalOffset`; with `additionalOffset = anchorY` (the anchor's item-local y in pre-transform coords), the chat list — rotated 180° at the layer — renders the anchor at the visual top of the content area. The `forEachVisibleItemNode` walk is safe because tapping the in-page anchor link requires the bubble to be at least partially visible. - -#### 6. Replace the `scrollToAnchor` stub - -In `ChatMessageRichDataBubbleContentNode.swift`: - -```swift -private func scrollToAnchor(_ anchor: String) { - guard let item = self.item else { return } - if anchor.isEmpty { - item.controllerInteraction.scrollToMessageId(item.message.index, 0.0) - } else { - item.controllerInteraction.scrollToMessageIdWithAnchor(item.message.index, anchor) - } -} -``` - -Empty anchor (the `#` with no fragment case) keeps the existing "scroll to message top" behavior. - -## Files touched - -| File | Change | -|---|---| -| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleContentNode/Sources/ChatMessageBubbleContentNode.swift` | Add `open func getAnchorRect(anchor:) -> CGRect?` returning `nil`. | -| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` | Override `getAnchorRect`; rewrite `scrollToAnchor` body. | -| `submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift` | Add public `getAnchorRect(anchor:)`. | -| `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` | New `scrollToMessageIdWithAnchor` field + init param + assignment. | -| `submodules/TelegramUI/Sources/ChatController.swift` | Real implementation of the closure. | -| 6 no-op stub sites | Add `scrollToMessageIdWithAnchor: { _, _ in }` next to existing stub. | - -## What is *not* changed - -- No new types in `Display/`, `AccountContext/`, or `TelegramCore/`. -- No changes to `MessageHistoryScrollToSubject` or `ChatHistoryLocation`. -- No changes to `InstantPageUI/` (the layout-walking logic is replicated in the rich-data bubble file rather than exported, since it's both small and specialized for the embedded layout). -- No changes to the existing `scrollToMessageId(_, CGFloat)` closure or its 7 call sites' signatures. - -## Verification - -There are no unit tests in this project. Verification is a full Bazel build: - -```sh -source ~/.zshrc 2>/dev/null; \ -python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build \ - --configurationPath build-system/appstore-configuration.json \ - --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ - --gitCodesigningType development --gitCodesigningUseCurrent \ - --buildNumber=1 --configuration=debug_sim_arm64 -``` - -Manual smoke test in the simulator: open a chat that contains a webpage message rendered as a rich-data bubble with an instant page that has internal anchors (e.g., a Wikipedia article with section links or footnote references). Tap a section link or footnote link; the chat should scroll so that the target line lands at the top of the visible content area. From da5a92c1bed023211835f5bfe236f891a356b1b1 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Sat, 2 May 2026 10:58:18 +0200 Subject: [PATCH 69/69] InstantPage tables: stroke all borders in one path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawInTile previously stroked each cell's full perimeter, double-drawing every interior gridline; visible now that the rich-data chat bubble uses tableBorderColor at 0.25 alpha. Stroking each segment in its own strokePath call would also have left ~1pt² overdraw at every interior 4-cell junction (where a horizontal divider crosses a vertical one) and at every T-junction with the outer rounded rect — each strokePath rasterizes independently and composites against the previous result. Build a single CGMutablePath containing each cell's interior top/left segment (skipping cells on the table's top/left boundary) plus the outer rounded perimeter rect, and call strokePath once. CGContextStrokePath fills the union of all stroke geometries as a single fill op, so each pixel is painted exactly once regardless of how many segments overlap. Empty cells (text == nil) are no longer skipped wholesale: their fill and text remain gated on text != nil (preserves today's no-fill-for- empty behavior), but their interior divider segments still get appended so divider continuity is preserved around them. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...stant-page-table-border-overdraw-design.md | 109 ------------------ .../Sources/InstantPageTableItem.swift | 78 +++++++------ 2 files changed, 45 insertions(+), 142 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md diff --git a/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md b/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md deleted file mode 100644 index a45db015c2..0000000000 --- a/docs/superpowers/specs/2026-05-01-instant-page-table-border-overdraw-design.md +++ /dev/null @@ -1,109 +0,0 @@ -# InstantPage table borders: stop drawing shared edges twice - -## Problem - -`submodules/InstantPageUI/Sources/InstantPageTableItem.swift` draws table borders per cell: each cell strokes its full perimeter (either via `context.stroke(bounds)` for interior cells, or via `context.drawPath(.stroke)` on a rounded path for the four table-corner cells). Every interior grid line is the boundary between two adjacent cells, so it is stroked twice. - -This was visually invisible while `tableBorderColor` was opaque. With the in-flight change in `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift` setting `tableBorderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25)`, double-stroked interior lines composite to ~44% alpha while the once-stroked outer perimeter shows at the intended 25%. The grid looks darker than the frame. - -## Goal - -Each border line — interior dividers and the outer perimeter — is stroked exactly once. - -## Non-goals - -- No change to `cell.adjacentSides`, `TableSide`, `tableCornerRadius`, `tableBorderWidth`, or any frame-layout code in `layoutTableItem`. -- No public API change. -- No change to fill behavior (header rows, striped rows, rounded corners on outer cells). -- No change anywhere outside `InstantPageTableItem.swift`. - -## Design - -`drawInTile(context:)` is restructured into two passes. - -### Pass 1: per cell (existing loop) - -The current early `if cell.cell.text == nil { continue }` is removed — empty cells must still contribute border lines (see "Empty cells preserve divider continuity" below). Each piece of per-cell work is gated explicitly instead: - -For each cell, in order: - -1. **Fill.** If `cell.filled && cell.cell.text != nil` (the `text != nil` gate preserves today's behavior of not filling empty cells, including in striped/header rows): - - If `cell.adjacentSides` is non-empty, fill with the rounded path (built from `byRoundingCorners: cell.adjacentSides.uiRectCorner`, `cornerRadii: tableCornerRadius`). This preserves the rounded fill on the four table-corner cells. - - Otherwise, `context.fill(bounds)`. -2. **Interior dividers.** If `self.borderWidth > 0.0` (no `text != nil` gate — empty cells still draw their dividers): - - If `!cell.adjacentSides.contains(.top)`, stroke the cell's top edge — a line from `(0, 0)` to `(cell.frame.width, 0)` in cell-local coordinates. - - If `!cell.adjacentSides.contains(.left)`, stroke the cell's left edge — a line from `(0, 0)` to `(0, cell.frame.height)` in cell-local coordinates. - - No `.right` or `.bottom` line is drawn per cell; both are owned by Pass 2. -3. **Text.** `cell.textItem?.drawInTile(context: context)`, unchanged. Already gated on `textItem` being non-nil. - -The `context.translateBy` / `saveGState` / `restoreGState` wrapper around each cell stays the same; the line strokes happen inside the per-cell translation, in cell-local coordinates. - -#### Empty cells preserve divider continuity - -Today, `cell.cell.text == nil` cells are skipped entirely. Under the existing "stroke whole bounds" model that's harmless — adjacent non-empty cells stroke their full perimeters and cover the dividers around the empty cell with their own bottom/right strokes. - -Under the new "always top+left, never bottom+right" convention, an empty cell's omitted top would leave a gap in the divider between it and the row above (the row-above's bottom is no longer stroked, and the empty cell isn't there to stroke its top). Symmetric for left. So Pass 1's divider-line block must run for empty cells too. Fill and text remain gated to preserve existing visuals — the only behavior change for empty cells is that their top/left dividers are now drawn explicitly, restoring continuity that was previously provided incidentally by adjacent cells' overdraw. - -### Pass 2: outer border (new, runs once after the loop) - -If `self.borderWidth > 0.0`: - -```swift -let outerRect = CGRect( - x: self.borderWidth / 2.0, - y: self.borderWidth / 2.0, - width: self.totalWidth - self.borderWidth, - height: self.frame.height - self.borderWidth -) -let outerPath = UIBezierPath(roundedRect: outerRect, cornerRadius: tableCornerRadius) -context.addPath(outerPath.cgPath) -context.strokePath() -``` - -Coordinates: `drawInTile`'s context is in the table-content coordinate space (origin at the table's top-left, size `totalWidth × frame.height`), matching `InstantPageScrollableContentNode`'s draw setup. Cells in `layoutTableItem` start at `origin = (borderWidth/2, borderWidth/2)`, so the outer rect inset by `borderWidth/2` aligns the stroke center exactly on the cells' outer perimeter. - -### Stroke setup - -`context.setStrokeColor(self.theme.tableBorderColor.cgColor)` and `context.setLineWidth(self.borderWidth)` are set once at the top of `drawInTile`, before the per-cell loop, instead of being re-set on every iteration. They are invariant per table. `context.setFillColor(self.theme.tableHeaderColor.cgColor)` is set once at the top for the same reason. (Each cell's `saveGState` / `restoreGState` would otherwise discard and re-set these every iteration; hoisting is functionally identical and simpler.) - -### Draw order - -Outer border is stroked **after** every cell's fill. With a semi-transparent border this means the border composites on top of any underlying header/striped fill at the four rounded corners, matching today's `.fillStroke` semantics where the stroke draws after the fill. - -## Why this gives every line exactly once - -- **Interior horizontal divider between rows R and R+1.** This is the top edge of every cell starting in row R+1. Cells in row R+1 do not have `.top ∈ adjacentSides` (only row 0 does), so they all draw it. Cells in row R do not draw their bottom edge in this pass. -- **Interior vertical divider between columns C and C+1.** Symmetric: drawn as the left edge of every cell starting in column C+1. -- **Outer top edge.** Row 0 cells have `.top ∈ adjacentSides`, so they skip their top edge in Pass 1. The outer rounded rect stroke draws it once in Pass 2. -- **Outer left / right / bottom edges.** Same — Pass 1 never draws right or bottom; Pass 1 skips left when `.left ∈ adjacentSides`; Pass 2's outer stroke draws all four perimeter sides. - -### colspan / rowspan - -`adjacentSides` already encodes "this cell touches the table's outer boundary on this side" for the layout code's existing semantics. The new drawing logic depends only on `.top` and `.left`, both of which are computed from the cell's *starting* row/column (`i == 0` and `k == 0` checks in `layoutTableItem`). Spanning cells therefore behave correctly: - -- A colspan>1 cell starting at column 0 has `.left ∈ adjacentSides` → skips its left in Pass 1 (the outer border draws it). -- The next cell to its right starts at the column where the spanning cell ends; that next cell draws its own left edge, which is the spanning cell's right boundary. -- Inside the spanning cell's footprint there is no other cell to draw an internal divider, so no internal divider is drawn — correct. -- Same logic in the rowspan dimension via `.top`. - -The existing quirk where a rowspan>1 cell starting at the last row has `.bottom ∈ adjacentSides` (instead of computing on its end row) does not affect Pass 1, which never reads `.bottom`. - -### Edge cases - -- **No border (`borderWidth == 0`)**: Pass 1's interior-divider block is skipped; Pass 2 is skipped. Fill behavior unchanged. -- **Single-row table**: every cell has `.top ∈ adjacentSides`. Pass 1 draws no top edges (correct — no interior horizontal divider to draw). Outer border draws all four sides. -- **Single-column table**: every cell has `.left ∈ adjacentSides`. Pass 1 draws no left edges. Outer border draws all four sides. -- **1×1 table**: one cell with all four sides in `adjacentSides`. Pass 1 draws nothing. Pass 2 draws the outer rounded rect. -- **Cells without `cell.text`**: see "Empty cells preserve divider continuity" above. The early continue is removed; fill and text remain gated on `text != nil` (preserving today's no-fill behavior for empty cells); dividers run unconditionally so divider continuity is preserved. -- **Empty `cells`**: `totalWidth` is 0 in this case (`InstantPageTableItem(frame: CGRect(), totalWidth: 0.0, ...)` from the `rows.count == 0` early return in `layoutTableItem`). The outer rect would have negative width if `borderWidth > 0`. Guard Pass 2 with `self.totalWidth > 0` (or skip the whole `drawInTile` body when the cell list is empty — same effect). - -## File to modify - -- `submodules/InstantPageUI/Sources/InstantPageTableItem.swift`, function `drawInTile(context:)` only. - -## Verification - -This is a visual change with no tests. Verification path: - -1. Full Bazel build per CLAUDE.md, `--continueOnError`, `--configuration=debug_sim_arm64`. -2. Open an Instant View page that contains a table inside the rich-data chat bubble (where `tableBorderColor.withMultipliedAlpha(0.25)` is in effect) and visually confirm interior gridlines and outer perimeter are the same alpha. Also open a non-bubble Instant View page (where `tableBorderColor` is opaque) and confirm no visual regression. diff --git a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift index 8be13ed574..56c5161f69 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTableItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTableItem.swift @@ -144,54 +144,66 @@ public final class InstantPageTableItem: InstantPageScrollableItem { } public func drawInTile(context: CGContext) { + let hasBorder = self.borderWidth > 0.0 + + if hasBorder { + context.setStrokeColor(self.theme.tableBorderColor.cgColor) + context.setLineWidth(self.borderWidth) + } + context.setFillColor(self.theme.tableHeaderColor.cgColor) + + let borderPath = CGMutablePath() + for cell in self.cells { - if cell.cell.text == nil { - continue - } context.saveGState() context.translateBy(x: cell.frame.minX, y: cell.frame.minY) - - let hasBorder = self.borderWidth > 0.0 + let bounds = CGRect(origin: CGPoint(), size: cell.frame.size) var path: UIBezierPath? if !cell.adjacentSides.isEmpty { path = UIBezierPath(roundedRect: bounds, byRoundingCorners: cell.adjacentSides.uiRectCorner, cornerRadii: CGSize(width: tableCornerRadius, height: tableCornerRadius)) } - if cell.filled { - context.setFillColor(self.theme.tableHeaderColor.cgColor) - } - if self.borderWidth > 0.0 { - context.setStrokeColor(self.theme.tableBorderColor.cgColor) - context.setLineWidth(borderWidth) - } - if let path = path { - context.addPath(path.cgPath) - var drawMode: CGPathDrawingMode? - switch (cell.filled, hasBorder) { - case (true, false): - drawMode = .fill - case (true, true): - drawMode = .fillStroke - case (false, true): - drawMode = .stroke - default: - break - } - if let drawMode = drawMode { - context.drawPath(using: drawMode) - } - } else { - if cell.filled { + + if cell.filled && cell.cell.text != nil { + if let path = path { + context.addPath(path.cgPath) + context.fillPath() + } else { context.fill(bounds) } - if hasBorder { - context.stroke(bounds) - } } + if let textItem = cell.textItem { textItem.drawInTile(context: context) } + context.restoreGState() + + if hasBorder { + if !cell.adjacentSides.contains(.top) { + borderPath.move(to: CGPoint(x: cell.frame.minX, y: cell.frame.minY)) + borderPath.addLine(to: CGPoint(x: cell.frame.maxX, y: cell.frame.minY)) + } + if !cell.adjacentSides.contains(.left) { + borderPath.move(to: CGPoint(x: cell.frame.minX, y: cell.frame.minY)) + borderPath.addLine(to: CGPoint(x: cell.frame.minX, y: cell.frame.maxY)) + } + } + } + + if hasBorder && self.totalWidth > 0.0 { + let outerRect = CGRect( + x: self.borderWidth / 2.0, + y: self.borderWidth / 2.0, + width: self.totalWidth - self.borderWidth, + height: self.frame.height - self.borderWidth + ) + borderPath.addPath(UIBezierPath(roundedRect: outerRect, cornerRadius: tableCornerRadius).cgPath) + } + + if hasBorder { + context.addPath(borderPath) + context.strokePath() } }