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

Notable changes by category:

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

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

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

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

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

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

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

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

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

305 lines
17 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import AsyncDisplayKit
import Display
import ContextUI
import UndoUI
import AccountContext
import ChatMessageItemView
import ChatMessageItemCommon
import ChatControllerInteraction
import Pasteboard
import TelegramStringFormatting
import TelegramPresentationData
import AvatarNode
import ChatPresentationInterfaceState
private enum OptionsId: Hashable {
case item
case message
}
extension ChatControllerImpl {
func openPollOptionContextMenu(optionId: Data, params: ChatControllerInteraction.LongTapParams) -> Void {
guard let message = params.message, let poll = message.media.first(where: { $0 is TelegramMediaPoll }) as? TelegramMediaPoll, let pollOptionIndex = poll.options.firstIndex(where: { $0.opaqueIdentifier == optionId }), let contentNode = params.contentNode else {
return
}
let pollOption = poll.options[pollOptionIndex]
var selectedOptions: [Data] = []
if let voters = poll.results.voters {
for voter in voters {
if voter.selected {
selectedOptions.append(voter.opaqueIdentifier)
}
}
}
var addedByPeer: Signal<EnginePeer?, NoError> = .single(nil)
if let peerId = pollOption.addedBy {
addedByPeer = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId))
}
let _ = combineLatest(
queue: Queue.mainQueue(),
contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState: self.presentationInterfaceState, context: self.context, messages: [message], controllerInteraction: self.controllerInteraction, selectAll: false, interfaceInteraction: self.interfaceInteraction, messageNode: params.messageNode as? ChatMessageItemView),
addedByPeer
).start(next: { [weak self] actions, addedByPeer in
guard let self else {
return
}
var items: [ContextMenuItem] = []
if !poll.isClosed && (selectedOptions.isEmpty || !poll.revotingDisabled) {
if selectedOptions.contains(pollOption.opaqueIdentifier) {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Poll_RetractOptionVote, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Unvote"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
guard let self else {
return
}
c?.dismiss(result: .default, completion: {
var updatedOptions = selectedOptions
updatedOptions.removeAll(where: { $0 == pollOption.opaqueIdentifier } )
self.controllerInteraction?.requestSelectMessagePollOptions(message.id, updatedOptions)
})
})))
} else {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Poll_VoteOption, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/StopPoll"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
guard let self else {
return
}
c?.dismiss(result: .default, completion: {
var updatedOptions = selectedOptions
if !poll.kind.multipleAnswers {
updatedOptions = []
}
updatedOptions.append(pollOption.opaqueIdentifier)
self.controllerInteraction?.requestSelectMessagePollOptions(message.id, updatedOptions)
})
})))
}
}
var canReply = canReplyInChat(self.presentationInterfaceState, accountPeerId: self.context.account.peerId)
if !canSendMessagesToChat(self.presentationInterfaceState) && (self.presentationInterfaceState.copyProtectionEnabled || message.isCopyProtected()) {
canReply = false
}
if canReply {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Poll_ReplyToOption, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.actionSheet.primaryTextColor)
}, action: { [weak self] c, _ in
guard let self else {
return
}
self.interfaceInteraction?.setupReplyMessage(message.id, .pollOption(pollOption.opaqueIdentifier), { transition, completed in
c?.dismiss(result: .custom(transition), completion: {
completed()
})
})
})))
}
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuCopy, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
storeMessageTextInPasteboard(pollOption.text, entities: pollOption.entities)
self.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_TextCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .current)
})))
var isReplyThreadHead = false
if case let .replyThread(replyThreadMessage) = self.presentationInterfaceState.chatLocation {
isReplyThreadHead = message.id == replyThreadMessage.effectiveTopId
}
if message.id.namespace == Namespaces.Message.Cloud, let channel = message.peers[message.id.peerId] as? TelegramChannel, !channel.isMonoForum, !isReplyThreadHead {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Conversation_ContextMenuCopyLink, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] _, f in
guard let self else {
return
}
var threadMessageId: EngineMessage.Id?
if case let .replyThread(replyThreadMessage) = self.presentationInterfaceState.chatLocation {
threadMessageId = replyThreadMessage.effectiveMessageId
}
let _ = (self.context.engine.messages.exportMessageLink(peerId: message.id.peerId, messageId: message.id, isThread: threadMessageId != nil)
|> map { result -> String? in
return result
}
|> deliverOnMainQueue).startStandalone(next: { [weak self] link in
guard let self, let link else {
return
}
let encodeBase64URL: (Data) -> String = { data in
var string = data.base64EncodedString()
string = string
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
string = string.replacingOccurrences(of: "=", with: "")
return string
}
let optionId = encodeBase64URL(pollOption.opaqueIdentifier)
UIPasteboard.general.string = link + "?option=\(optionId)"
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
var warnAboutPrivate = false
if case .peer = self.presentationInterfaceState.chatLocation {
if channel.addressName == nil {
warnAboutPrivate = true
}
}
Queue.mainQueue().after(0.2, {
if warnAboutPrivate {
self.controllerInteraction?.displayUndo(.linkCopied(title: nil, text: presentationData.strings.Conversation_PrivateMessageLinkCopiedLong))
} else {
self.controllerInteraction?.displayUndo(.linkCopied(title: nil, text: presentationData.strings.Conversation_LinkCopied))
}
})
})
f(.default)
})))
}
if let addedByPeer, let date = pollOption.date {
var canRemove = false
if !poll.isClosed {
if poll.isCreator {
canRemove = true
} else if addedByPeer.id == self.context.account.peerId {
let pollConfiguration = PollConfiguration.with(appConfiguration: self.context.currentAppConfiguration.with { $0 })
let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970)
if currentTime < date + pollConfiguration.pollOptionDeletePeriod {
canRemove = true
}
}
}
if canRemove {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Poll_RemoveOption, 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 _ = self.context.engine.messages.deletePollOption(messageId: message.id, opaqueIdentifier: pollOption.opaqueIdentifier).start()
})))
}
items.append(.separator)
var peerName = addedByPeer.compactDisplayTitle
if peerName.count > 20 {
peerName = peerName.prefix(20) + "..."
}
peerName = "**\(peerName)**"
let dateText = humanReadableStringForTimestamp(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, timestamp: date, alwaysShowTime: true, allowYesterday: true, format: HumanReadableStringFormat(
dateFormatString: { value in
if addedByPeer.id == self.context.account.peerId {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestampYou_Date(value).string, ranges: [])
} else {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestamp_Date(peerName, value).string, ranges: [])
}
},
tomorrowFormatString: { value in
if addedByPeer.id == self.context.account.peerId {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestampYou_TodayAt(value).string, ranges: [])
} else {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestamp_TodayAt(peerName, value).string, ranges: [])
}
},
todayFormatString: { value in
if addedByPeer.id == self.context.account.peerId {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestampYou_TodayAt(value).string, ranges: [])
} else {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestamp_TodayAt(peerName, value).string, ranges: [])
}
},
yesterdayFormatString: { value in
if addedByPeer.id == self.context.account.peerId {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestampYou_YesterdayAt(value).string, ranges: [])
} else {
return PresentationStrings.FormattedString(string: self.presentationData.strings.Chat_PollOptionAddedTimestamp_YesterdayAt(peerName, value).string, ranges: [])
}
}
)).string
let avatarSize = CGSize(width: 24.0, height: 24.0)
items.append(.action(ContextMenuActionItem(text: dateText, textFont: .small, parseMarkdown: true, icon: { _ in return nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: self.context.account, peer: addedByPeer, size: avatarSize)), action: { [weak self] _, f in
f(.default)
guard let self else {
return
}
self.openPeer(peer: addedByPeer, navigation: .chat(textInputState: nil, subject: nil, peekData: nil), fromMessage: nil)
})))
}
self.canReadHistory.set(false)
var sources: [ContextController.Source] = []
sources.append(
ContextController.Source(
id: AnyHashable(OptionsId.item),
title: self.presentationData.strings.Chat_Poll_ContextMenu_SectionOption,
footer: self.presentationData.strings.Chat_Poll_ContextMenu_SectionsInfo,
source: .extracted(ChatTodoItemContextExtractedContentSource(chatNode: self.chatDisplayNode, contentNode: contentNode)),
items: .single(ContextController.Items(content: .list(items)))
)
)
let messageContentSource = ChatMessageContextExtractedContentSource(chatController: self, chatNode: self.chatDisplayNode, engine: self.context.engine, message: EngineMessage(message), selectAll: false, snapshot: true)
sources.append(
ContextController.Source(
id: AnyHashable(OptionsId.message),
title: self.presentationData.strings.Chat_Poll_ContextMenu_SectionPoll,
source: .extracted(messageContentSource),
items: .single(actions)
)
)
contentNode.onDismiss = { [weak messageContentSource] in
messageContentSource?.snapshotView?.removeFromSuperview()
}
let contextController = makeContextController(
presentationData: self.presentationData,
configuration: ContextController.Configuration(
sources: sources,
initialId: AnyHashable(OptionsId.item)
)
)
contextController.dismissed = { [weak self] in
self?.canReadHistory.set(true)
}
self.window?.presentInGlobalOverlay(contextController)
})
}
}
private struct PollConfiguration {
static var defaultValue: PollConfiguration {
return PollConfiguration(pollOptionDeletePeriod: 300)
}
let pollOptionDeletePeriod: Int32
init(pollOptionDeletePeriod: Int32) {
self.pollOptionDeletePeriod = pollOptionDeletePeriod
}
static func with(appConfiguration: AppConfiguration) -> PollConfiguration {
if let data = appConfiguration.data, let pollOptionDeletePeriod = data["poll_answer_delete_period"] as? Double {
return PollConfiguration(pollOptionDeletePeriod: Int32(pollOptionDeletePeriod))
} else {
return .defaultValue
}
}
}