Telegram-iOS/submodules/SettingsUI/Sources/Privacy and Security/IncomingMessagePrivacyScreen.swift
isaac d1aa0db537 Postbox -> TelegramEngine waves 46-93 (squashed)
Squash of 63 commits spanning waves 46-93 (plus interspersed docs commits)
of the gradual Postbox->TelegramEngine consumer-side migration.

Scope: 139 files changed, 2123 insertions(+), 452 deletions(-).

## Themes by wave-block

**Waves 46-58 — Peer field migrations + facade additions**
Foundational EnginePeer convenience init additions (PeerReference, RenderedPeer,
SelectivePrivacyPeer). Multiple `peer: Peer` field migrations across PeerInfo,
ChatList, and SettingsUI components.

**Waves 59-73 — peer field cascade + EnginePeer wrap drops**
Series of single- to two-file peer-field migrations; consumer-side wrap
removal (`EnginePeer(peer)` -> direct EnginePeer use); `as? TelegramUser`
cast conversion to `case let .user(...)` enum match. Wave 64: RenderedPeer
convenience init. Wave 68: SelectivePrivacyPeer convenience init.

**Waves 74-83 — controller-Node bridge cleanup + small migrations**
Wave-71 shadow-pattern cleanup at controller->Node bridges. Migrations of
ChatRecentActionsController.peer (74), PeerInfoMember (75), MentionChatInputPanelItem
(76), PassportUI SecureIdAuthController (77), AccountWithInfo + ShareController
(78), peerInputActivitiesPromise (79), InactiveChannel (80), BlockedPeers (81),
openHashtag resolveSignal (82), NotificationExceptionsList (83).

**Waves 84-90 — TelegramEngine.Resources facade migrations**
Per-method Shape-A/B sweeps converting `<ctx>.account.postbox.mediaBox.X(...)`
to `<ctx>.engine.resources.X(...)`. Wave 90 was a single-commit big sweep:
40 fetchedMediaResource sites in 25 files migrated to engine.resources.fetch
facade in one atomic pass with first-pass-clean build.

Methods covered: storeResourceData, completedResourcePath, cancelInteractiveResourceFetch,
resourceRangesStatus, resourceStatus, fetch (fetchedMediaResource).

**Waves 91-92 — additional type migrations**
Wave 91: ItemListWebsiteItem.peer + RecentSessionsController enum-case payload
+ openWebSession callback Peer? -> EnginePeer?.
Wave 92: ChatListController StateHolder.EntryContext status type
MediaResourceStatus -> EngineMediaResource.FetchStatus.

**Wave 93 — speculative `import Postbox` drop sweep**
Drop import from 7 wave-touched files where it became unused; restore in 5
files where bare PeerId/Message/MediaId/StoryId references escaped the
pre-flight regex. Includes one MediaId(...) -> EngineMedia.Id(...) swap in
InAppPurchaseManager to unlock its import drop.

## Build state

Final state at squash: clean Telegram/Telegram build at debug_sim_arm64.

## Persistent-state notes

- Pre-existing WIP unchanged across the squashed range:
  - build-system/bazel-rules/sourcekit-bazel-bsp submodule marker
  - Untracked: build-system/tulsi/, submodules/TgVoip/, third-party/libx264/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:48:15 +04:00

500 lines
24 KiB
Swift

import Foundation
import UIKit
import Display
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import TelegramUIPreferences
import TelegramStringFormatting
import ItemListUI
import PresentationDataUtils
import AccountContext
import UndoUI
import PremiumUI
import MessagePriceItem
private final class IncomingMessagePrivacyScreenArguments {
let context: AccountContext
let updateValue: (GlobalPrivacySettings.NonContactChatsPrivacy) -> Void
let disabledValuePressed: () -> Void
let infoLinkAction: () -> Void
let openExceptions: () -> Void
let openPremiumInfo: () -> Void
let openSetCustomStarsAmount: () -> Void
init(
context: AccountContext,
updateValue: @escaping (GlobalPrivacySettings.NonContactChatsPrivacy) -> Void,
disabledValuePressed: @escaping () -> Void,
infoLinkAction: @escaping () -> Void,
openExceptions: @escaping () -> Void,
openPremiumInfo: @escaping () -> Void,
openSetCustomStarsAmount: @escaping () -> Void
) {
self.context = context
self.updateValue = updateValue
self.disabledValuePressed = disabledValuePressed
self.infoLinkAction = infoLinkAction
self.openExceptions = openExceptions
self.openPremiumInfo = openPremiumInfo
self.openSetCustomStarsAmount = openSetCustomStarsAmount
}
}
private enum IncomingMessagePrivacySection: Int32 {
case header
case info
case price
case exceptions
}
public enum IncomingMessagePrivacyEntryTag: ItemListItemTag, Equatable {
case setPrice
case removeFee
public func isEqual(to other: ItemListItemTag) -> Bool {
if let other = other as? IncomingMessagePrivacyEntryTag, self == other {
return true
} else {
return false
}
}
}
private enum GlobalAutoremoveEntry: ItemListNodeEntry {
case header
case optionEverybody(value: GlobalPrivacySettings.NonContactChatsPrivacy)
case optionPremium(value: GlobalPrivacySettings.NonContactChatsPrivacy, isEnabled: Bool)
case optionChargeForMessages(value: GlobalPrivacySettings.NonContactChatsPrivacy, isEnabled: Bool)
case footer(value: GlobalPrivacySettings.NonContactChatsPrivacy)
case priceHeader
case price(value: Int64, maxValue: Int64, price: String, isEnabled: Bool)
case priceInfo(commission: Int32, value: String)
case exceptionsHeader
case exceptions(count: Int)
case exceptionsInfo
case info
var section: ItemListSectionId {
switch self {
case .header, .optionEverybody, .optionPremium, .optionChargeForMessages, .footer:
return IncomingMessagePrivacySection.header.rawValue
case .info:
return IncomingMessagePrivacySection.info.rawValue
case .priceHeader, .price, .priceInfo:
return IncomingMessagePrivacySection.price.rawValue
case .exceptionsHeader, .exceptions, .exceptionsInfo:
return IncomingMessagePrivacySection.exceptions.rawValue
}
}
var stableId: Int {
return self.sortIndex
}
var sortIndex: Int {
switch self {
case .header:
return 0
case .optionEverybody:
return 1
case .optionPremium:
return 2
case .optionChargeForMessages:
return 3
case .footer:
return 4
case .info:
return 5
case .priceHeader:
return 6
case .price:
return 7
case .priceInfo:
return 8
case .exceptionsHeader:
return 9
case .exceptions:
return 10
case .exceptionsInfo:
return 11
}
}
static func <(lhs: GlobalAutoremoveEntry, rhs: GlobalAutoremoveEntry) -> Bool {
return lhs.sortIndex < rhs.sortIndex
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! IncomingMessagePrivacyScreenArguments
switch self {
case .header:
return ItemListSectionHeaderItem(presentationData: presentationData, text: presentationData.strings.Privacy_Messages_SectionTitle, sectionId: self.section)
case let .optionEverybody(value):
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, title: presentationData.strings.Privacy_Messages_ValueEveryone, style: .left, checked: value == .everybody, zeroSeparatorInsets: false, sectionId: self.section, action: {
arguments.updateValue(.everybody)
})
case let .optionPremium(value, isEnabled):
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, icon: isEnabled ? nil : generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/Lock"), color: presentationData.theme.list.itemSecondaryTextColor), iconPlacement: .check, title: presentationData.strings.Privacy_Messages_ValueContactsAndPremium, style: .left, checked: isEnabled && value == .requirePremium, zeroSeparatorInsets: false, sectionId: self.section, action: {
if isEnabled {
arguments.updateValue(.requirePremium)
} else {
arguments.disabledValuePressed()
}
})
case let .optionChargeForMessages(value, isEnabled):
var isChecked = false
if case .paidMessages = value {
isChecked = true
}
return ItemListCheckboxItem(presentationData: presentationData, systemStyle: .glass, icon: isEnabled || isChecked ? nil : generateTintedImage(image: UIImage(bundleImageName: "Chat/Stickers/Lock"), color: presentationData.theme.list.itemSecondaryTextColor), iconPlacement: .check, title: presentationData.strings.Privacy_Messages_ChargeForMessages, style: .left, checked: isChecked, zeroSeparatorInsets: false, sectionId: self.section, action: {
arguments.updateValue(.paidMessages(StarsAmount(value: 400, nanos: 0)))
})
case let .footer(value):
let text: String
if case .paidMessages = value {
text = presentationData.strings.Privacy_Messages_ChargeForMessagesInfo
} else {
text = presentationData.strings.Privacy_Messages_SectionFooter
}
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section)
case .info:
return ItemListTextItem(presentationData: presentationData, text: .markdown(presentationData.strings.Privacy_Messages_PremiumInfoFooter), sectionId: self.section, linkAction: { _ in
arguments.infoLinkAction()
})
case .priceHeader:
return ItemListSectionHeaderItem(presentationData: presentationData, text: presentationData.strings.Privacy_Messages_MessagePrice, sectionId: self.section)
case let .price(value, maxValue, price, isEnabled):
return MessagePriceItem(theme: presentationData.theme, strings: presentationData.strings, systemStyle: .glass, isEnabled: isEnabled, minValue: 1, maxValue: maxValue, value: value, price: price, sectionId: self.section, updated: { value, _ in
arguments.updateValue(.paidMessages(StarsAmount(value: value, nanos: 0)))
}, openSetCustom: {
arguments.openSetCustomStarsAmount()
}, openPremiumInfo: {
arguments.openPremiumInfo()
})
case let .priceInfo(commission, value):
return ItemListTextItem(presentationData: presentationData, text: .markdown(presentationData.strings.Privacy_Messages_MessagePriceInfo("\(commission)", value).string), sectionId: self.section)
case .exceptionsHeader:
return ItemListSectionHeaderItem(presentationData: presentationData, text: presentationData.strings.Privacy_Messages_RemoveFeeHeader, sectionId: self.section)
case let .exceptions(count):
return ItemListDisclosureItem(presentationData: presentationData, systemStyle: .glass, title: presentationData.strings.Privacy_Messages_RemoveFee, label: count > 0 ? "\(count)" : "", sectionId: self.section, style: .blocks, action: {
arguments.openExceptions()
}, tag: IncomingMessagePrivacyEntryTag.removeFee)
case .exceptionsInfo:
return ItemListTextItem(presentationData: presentationData, text: .markdown(presentationData.strings.Privacy_Messages_RemoveFeeInfo), sectionId: self.section)
}
}
}
private struct IncomingMessagePrivacyScreenState: Equatable {
var updatedValue: GlobalPrivacySettings.NonContactChatsPrivacy
var disableFor: [EnginePeer.Id: SelectivePrivacyPeer]
}
private func incomingMessagePrivacyScreenEntries(presentationData: PresentationData, state: IncomingMessagePrivacyScreenState, enableSetting: Bool, isPremium: Bool, configuration: StarsSubscriptionConfiguration) -> [GlobalAutoremoveEntry] {
var entries: [GlobalAutoremoveEntry] = []
entries.append(.header)
entries.append(.optionEverybody(value: state.updatedValue))
entries.append(.optionPremium(value: state.updatedValue, isEnabled: enableSetting))
if configuration.paidMessagesAvailable {
entries.append(.optionChargeForMessages(value: state.updatedValue, isEnabled: isPremium))
}
if case let .paidMessages(amount) = state.updatedValue {
entries.append(.footer(value: state.updatedValue))
entries.append(.priceHeader)
let usdRate = Double(configuration.usdWithdrawRate) / 1000.0 / 100.0
let price = "~\(formatTonUsdValue(amount.value, divide: false, rate: usdRate, dateTimeFormat: presentationData.dateTimeFormat))"
entries.append(.price(value: amount.value, maxValue: configuration.paidMessageMaxAmount, price: price, isEnabled: isPremium))
entries.append(.priceInfo(commission: configuration.paidMessageCommissionPermille / 10, value: price))
if isPremium {
entries.append(.exceptionsHeader)
entries.append(.exceptions(count: state.disableFor.count))
entries.append(.exceptionsInfo)
}
} else {
entries.append(.footer(value: state.updatedValue))
entries.append(.info)
}
return entries
}
public func incomingMessagePrivacyScreen(context: AccountContext, value: GlobalPrivacySettings.NonContactChatsPrivacy, exceptions: SelectivePrivacySettings, update: @escaping (GlobalPrivacySettings.NonContactChatsPrivacy) -> Void, focusOnItemTag: IncomingMessagePrivacyEntryTag? = nil) -> ViewController {
var disableFor: [EnginePeer.Id: SelectivePrivacyPeer] = [:]
if case let .enableContacts(value, _, _, _) = exceptions {
disableFor = value
}
let initialState = IncomingMessagePrivacyScreenState(
updatedValue: value,
disableFor: disableFor
)
let statePromise = ValuePromise(initialState, ignoreRepeated: true)
let stateValue = Atomic(value: initialState)
let updateState: ((IncomingMessagePrivacyScreenState) -> IncomingMessagePrivacyScreenState) -> Void = { f in
statePromise.set(stateValue.modify { f($0) })
}
let configuration = StarsSubscriptionConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var presentInCurrentControllerImpl: ((ViewController) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
var dismissImpl: (() -> Void)?
let _ = dismissImpl
let _ = pushControllerImpl
let _ = presentControllerImpl
let actionsDisposable = DisposableSet()
let addPeerDisposable = MetaDisposable()
actionsDisposable.add(addPeerDisposable)
let updateTimeoutDisposable = MetaDisposable()
actionsDisposable.add(updateTimeoutDisposable)
let presentationData = context.sharedContext.currentPresentationData.with({ $0 })
let arguments = IncomingMessagePrivacyScreenArguments(
context: context,
updateValue: { value in
updateState { state in
var state = state
state.updatedValue = value
return state
}
},
disabledValuePressed: {
presentInCurrentControllerImpl?(UndoOverlayController(presentationData: presentationData, content: .premiumPaywall(title: presentationData.strings.Privacy_Messages_PremiumToast_Title, text: presentationData.strings.Privacy_Messages_PremiumToast_Text, customUndoText: presentationData.strings.Privacy_Messages_PremiumToast_Action, timeout: nil, linkAction: { _ in
}), elevatedLayout: false, action: { action in
if case .undo = action {
let controller = PremiumIntroScreen(context: context, source: .settings)
pushControllerImpl?(controller)
}
return false
}))
},
infoLinkAction: {
let controller = PremiumIntroScreen(context: context, source: .settings)
pushControllerImpl?(controller)
},
openExceptions: {
var peerIds: [EnginePeer.Id: SelectivePrivacyPeer] = [:]
updateState { state in
peerIds = state.disableFor
return state
}
if peerIds.isEmpty {
let controller = context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: context, mode: .chatSelection(ContactMultiselectionControllerMode.ChatSelection(
title: presentationData.strings.PrivacySettings_SearchUsersTitle,
searchPlaceholder: presentationData.strings.PrivacySettings_SearchUsersPlaceholder,
selectedChats: Set(),
additionalCategories: ContactMultiselectionControllerAdditionalCategories(categories: [], selectedCategories: Set()),
chatListFilters: nil,
onlyUsers: false,
disableChannels: true,
disableBots: true,
disableContacts: true
))))
addPeerDisposable.set((controller.result
|> take(1)
|> deliverOnMainQueue).start(next: { [weak controller] result in
var peerIds: [ContactListPeerId] = []
if case let .result(peerIdsValue, _) = result {
peerIds = peerIdsValue
}
if peerIds.isEmpty {
controller?.dismiss()
return
}
let filteredIds = peerIds.compactMap { peerId -> EnginePeer.Id? in
if case let .peer(value) = peerId {
return value
} else {
return nil
}
}
let _ = (context.engine.data.get(
EngineDataMap(filteredIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init)),
EngineDataMap(filteredIds.map(TelegramEngine.EngineData.Item.Peer.ParticipantCount.init))
)
|> map { peerMap, participantCountMap -> [EnginePeer.Id: SelectivePrivacyPeer] in
var updatedPeers: [EnginePeer.Id: SelectivePrivacyPeer] = [:]
var existingIds = Set(updatedPeers.values.map { $0.peer.id })
for peerId in peerIds {
guard case let .peer(peerId) = peerId else {
continue
}
if let maybePeer = peerMap[peerId], let peer = maybePeer, !existingIds.contains(peerId) {
existingIds.insert(peerId)
var participantCount: Int32?
if case let .channel(channel) = peer, case .group = channel.info {
if let maybeParticipantCount = participantCountMap[peerId], let participantCountValue = maybeParticipantCount {
participantCount = Int32(participantCountValue)
}
}
updatedPeers[peer.id] = SelectivePrivacyPeer(peer: peer, participantCount: participantCount)
}
}
return updatedPeers
}
|> deliverOnMainQueue).start(next: { updatedPeerIds in
controller?.dismiss()
updateState { state in
var updatedState = state
updatedState.disableFor = updatedPeerIds
return updatedState
}
let settings: SelectivePrivacySettings = .enableContacts(enableFor: updatedPeerIds, disableFor: [:], enableForPremium: false, enableForBots: false)
let _ = context.engine.privacy.updateSelectiveAccountPrivacySettings(type: .noPaidMessages, settings: settings).start()
})
}))
controller.navigationPresentation = .modal
pushControllerImpl?(controller)
} else {
let controller = selectivePrivacyPeersController(context: context, title: presentationData.strings.Privacy_Messages_Exceptions_Title, footer: presentationData.strings.Privacy_Messages_RemoveFeeInfo, hideContacts: true, initialPeers: peerIds, initialEnableForPremium: false, displayPremiumCategory: false, initialEnableForBots: false, displayBotsCategory: false, updated: { updatedPeerIds, _, _ in
updateState { state in
var updatedState = state
updatedState.disableFor = updatedPeerIds
return updatedState
}
let settings: SelectivePrivacySettings = .enableContacts(enableFor: updatedPeerIds, disableFor: [:], enableForPremium: false, enableForBots: false)
let _ = context.engine.privacy.updateSelectiveAccountPrivacySettings(type: .noPaidMessages, settings: settings).start()
})
pushControllerImpl?(controller)
}
},
openPremiumInfo: {
var replaceImpl: ((ViewController) -> Void)?
let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .messagePrivacy, forceDark: false, action: {
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .messageEffects, forceDark: false, dismissed: nil)
replaceImpl?(controller)
}, dismissed: nil)
replaceImpl = { [weak controller] c in
controller?.replace(with: c)
}
pushControllerImpl?(controller)
},
openSetCustomStarsAmount: {
var currentAmount: StarsAmount = StarsAmount(value: 1, nanos: 0)
if case let .paidMessages(value) = stateValue.with({ $0 }).updatedValue {
currentAmount = value
}
let fractionAfterCommission = configuration.paidMessageCommissionPermille / 10
let starsScreen = context.sharedContext.makeStarsWithdrawalScreen(context: context, subject: .enterAmount(
current: currentAmount,
minValue: StarsAmount(value: 1, nanos: 0),
fractionAfterCommission: Int(fractionAfterCommission), kind: .privacy,
completion: { amount in
updateState { state in
var state = state
state.updatedValue = .paidMessages(StarsAmount(value: amount, nanos: 0))
return state
}
}
))
pushControllerImpl?(starsScreen)
}
)
let enableSetting: Signal<Bool, NoError> = context.engine.data.subscribe(
TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId),
TelegramEngine.EngineData.Item.Configuration.App()
)
|> map { accountPeer, appConfig -> Bool in
if let accountPeer, accountPeer.isPremium {
return true
}
if let data = appConfig.data, let setting = data["new_noncontact_peers_require_premium_without_ownpremium"] as? Bool {
if setting {
return true
}
}
return false
}
|> distinctUntilChanged
let signal = combineLatest(queue: .mainQueue(),
context.sharedContext.presentationData,
statePromise.get(),
enableSetting
)
|> map { presentationData, state, enableSetting -> (ItemListControllerState, (ItemListNodeState, Any)) in
let rightNavigationButton: ItemListNavigationButton? = nil
let title: ItemListControllerTitle = .text(presentationData.strings.Privacy_Messages_Title)
let entries: [GlobalAutoremoveEntry] = incomingMessagePrivacyScreenEntries(presentationData: presentationData, state: state, enableSetting: enableSetting, isPremium: context.isPremium, configuration: configuration)
let animateChanges = false
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: title, leftNavigationButton: nil, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true)
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: focusOnItemTag, emptyStateItem: nil, crossfadeState: false, animateChanges: animateChanges, scrollEnabled: true)
return (controllerState, (listState, arguments))
}
|> afterDisposed {
actionsDisposable.dispose()
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, p in
guard let controller else {
return
}
controller.present(c, in: .window(.root), with: p)
}
presentInCurrentControllerImpl = { [weak controller] c in
guard let controller else {
return
}
controller.forEachController { c in
if let c = c as? UndoOverlayController {
c.dismiss()
}
return true
}
controller.present(c, in: .current, with: nil)
}
pushControllerImpl = { [weak controller] c in
controller?.push(c)
}
controller.attemptNavigation = { _ in
let updatedValue = stateValue.with({ $0 }).updatedValue
if !context.isPremium, case .paidMessages = updatedValue {
} else {
update(updatedValue)
}
return true
}
dismissImpl = { [weak controller] in
controller?.dismiss()
}
if let focusOnItemTag {
var didFocusOnItem = false
controller.afterTransactionCompleted = { [weak controller] in
if !didFocusOnItem, let controller {
controller.forEachItemNode { itemNode in
if let itemNode = itemNode as? ItemListItemNode, let tag = itemNode.tag, tag.isEqual(to: focusOnItemTag) {
didFocusOnItem = true
itemNode.displayHighlight()
}
}
}
}
}
return controller
}