Skip to content
7 changes: 7 additions & 0 deletions Telegram/Telegram-iOS/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -16598,3 +16598,10 @@ Error: %8$@";
"TextProcessing.ActionGenerate" = "Generate";
"TextProcessing.StyleList.Prompt" = "Prompt";
"TextProcessing.PromptPlaceholder" = "Generate text, formulas, or tables...";

"Profile.SupportInfo" = "Support Info";
"Profile.SupportInfoEmpty" = "Add support info";
"Profile.SupportInfoAuthor" = "%1$@, %2$@";
"Profile.SupportInfoPlaceholder" = "Support info";
"Conversation.SupportCheckInfo" = "⚠️ Check Info";
"Conversation.SupportCheckInfoStatus" = "⚠️ Check Info - %@";
6 changes: 6 additions & 0 deletions submodules/AccountContext/Sources/AccountContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,12 @@ public protocol AccountContext: AnyObject {

var isPremium: Bool { get }
var isFrozen: Bool { get }
/// Whether this account authorised with a TSupport volunteer phone number.
///
/// Seeded synchronously from the persisted account record, then kept in sync with the
/// account peer's phone number. Prefer this over `account.isSupportUser`, which is fixed
/// at construction and so is stale for the first session after a QR-code login.
var isSupportUser: Bool { get }
var userLimits: EngineConfiguration.UserLimits { get }
var peerNameColors: PeerNameColors { get }

Expand Down
29 changes: 28 additions & 1 deletion submodules/AvatarNode/Sources/AvatarNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,27 @@ public final class AvatarNode: ASDisplayNode {
displayDimensions: CGSize = CGSize(width: 60.0, height: 60.0),
storeUnrounded: Bool = false
) {
// Peer photos are suppressed for support volunteers outside the profile screen
// (D19). setPeerV2 is an image-only fast path with no monogram rendering, so
// suppressing the photo here would leave a blank circle. Delegate to setPeer,
// which renders the letter monogram exactly as it does for a peer with no photo.
if genericContext.isSupportUser {
self.setPeer(
context: genericContext,
account: account,
theme: theme,
peer: peer,
authorOfMessage: authorOfMessage,
overrideImage: overrideImage,
emptyColor: emptyColor,
clipStyle: clipStyle,
synchronousLoad: synchronousLoad,
displayDimensions: displayDimensions,
storeUnrounded: storeUnrounded
)
return
}

let smallProfileImage = peer?.smallProfileImage
let params = Params(
peerId: peer?.id,
Expand Down Expand Up @@ -839,7 +860,13 @@ public final class AvatarNode: ASDisplayNode {
icon = .storyIcon
}
} else if peer?.restrictionText(platform: "ios", contentSettings: genericContext.currentContentSettings.with { $0 }) == nil {
representation = peer?.smallProfileImage
// Support volunteers see a stream of strangers; peer photos are suppressed
// outside the profile screen (D19). Leaving `representation` nil makes
// AvatarNode fall back to its letter monogram, so layout is unchanged.
// The profile screen uses PeerInfoAvatarListNode and is unaffected.
if !genericContext.isSupportUser {
representation = peer?.smallProfileImage
}
}

let updatedState: AvatarNodeState = .peerAvatar(peer?.id ?? EnginePeer.Id(0), peer?.nameColor, peer?.displayLetters ?? [], representation, clipStyle, cutoutRect)
Expand Down
6 changes: 6 additions & 0 deletions submodules/ChatListUI/Sources/ChatListController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2948,6 +2948,12 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController

private weak var storyCameraTooltip: TooltipScreen?
fileprivate func openStoryCamera(fromList: Bool, gesturePullOffset: CGFloat? = nil) {
// Support volunteers cannot post stories (D16). Guarded at this choke point because
// seven call sites reach it — camera button, pull gesture, deep links. Story
// *viewing* is unaffected; it uses the story container controllers.
guard !self.context.isSupportUser else {
return
}
guard !self.context.isFrozen else {
let controller = self.context.sharedContext.makeAccountFreezeInfoScreen(context: self.context)
self.push(controller)
Expand Down
6 changes: 4 additions & 2 deletions submodules/ChatListUI/Sources/Node/ChatListItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1885,7 +1885,9 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
enablePreview = false
}

self.avatarNode.setStoryStats(storyStats: storyState.flatMap { storyState in
// Support volunteers do not post or browse stories from the queue, so the ring is
// noise on every row (D19 companion). Passing nil leaves the avatar undecorated.
self.avatarNode.setStoryStats(storyStats: item.context.isSupportUser ? nil : storyState.flatMap { storyState in
return AvatarNode.StoryStats(
totalCount: storyState.stats.totalCount,
unseenCount: storyState.stats.unseenCount,
Expand Down Expand Up @@ -2080,7 +2082,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {

if isKnown {
let photo = personalPhoto ?? profilePhoto
if let photo = photo, item.context.sharedContext.energyUsageSettings.loopEmoji, (!photo.videoRepresentations.isEmpty || photo.emojiMarkup != nil) {
if let photo = photo, !item.context.isSupportUser, item.context.sharedContext.energyUsageSettings.loopEmoji, (!photo.videoRepresentations.isEmpty || photo.emojiMarkup != nil) {
let videoNode: AvatarVideoNode
if let current = strongSelf.avatarVideoNode {
videoNode = current
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,65 @@ private func loadCountryCodes() -> [Country] {
}
}

countryCodesByPrefix = countriesByPrefix
let (tsupportCountries, tsupportCountriesByPrefix) = applyTSupportCountry(countries: result, countriesByPrefix: countriesByPrefix)
countryCodesByPrefix = tsupportCountriesByPrefix

return result
return tsupportCountries
}

private var countryCodes: [Country] = loadCountryCodes()
private var countryCodesByPrefix: [String: (Country, Country.CountryCode)] = [:]

private let tsupportCountryId = "TS"
private let tsupportCountryCode = "42"
/// Every allocated support region code begins `424` — see `SupportAccount.phoneNumberPrefix`.
private let tsupportRegionPrefix = "424"

/// Installs the TSupport pseudo-country into the active country list.
///
/// The list is replaced wholesale in three places — both `loadServerCountryCodes` overloads
/// and `setupCountryCodes` — so an entry present only in `PhoneCountries.txt` is silently
/// discarded the moment the server-provided list arrives. Every replacement must route
/// through here.
///
/// The entry is `hidden`, so it never appears in the country picker a regular user browses,
/// but `+42…` still resolves while typing. Registering the `42` prefix is safe for the real
/// `+420`/`+421`/`+423` countries because `lookupCountryIdByNumber` matches longest-prefix
/// first and stops as soon as a shorter code follows a longer one.
private func applyTSupportCountry(
countries: [Country],
countriesByPrefix: [String: (Country, Country.CountryCode)]
) -> ([Country], [String: (Country, Country.CountryCode)]) {
// Rebuilt rather than reused: the entry parsed out of PhoneCountries.txt is not hidden.
var updatedCountries = countries.filter { $0.id != tsupportCountryId }
var updatedByPrefix = countriesByPrefix

let code = Country.CountryCode(code: tsupportCountryCode, prefixes: [], patterns: [])
let country = Country(
id: tsupportCountryId,
name: "TSupport",
localizedName: nil,
countryCodes: [code],
hidden: true
)
updatedCountries.append(country)
updatedByPrefix[code.code] = (country, code)

// The server-provided list ships Telegram's test country "Y-land" (ISO `YL`, flag
// suppressed at CountryList.swift:19) on dialing code 42, registered under
// `code + prefix` keys — so it owns not just "42" but longer keys such as "42490".
// `lookupCountryIdByNumber` matches longest-prefix-first, so claiming only "42" loses
// the label to Y-land as soon as a region code is typed. Claim every 424* key instead.
//
// Safe for real countries: 424 is unassigned in E.164, and 420 (Czech Republic),
// 421 (Slovakia) and 423 (Liechtenstein) do not start with it.
for key in Array(updatedByPrefix.keys) where key.hasPrefix(tsupportRegionPrefix) {
updatedByPrefix[key] = (country, code)
}

return (updatedCountries, updatedByPrefix)
}

public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccountManagerTypes>, engine: TelegramEngineUnauthorized, completion: @escaping () -> Void) {
let _ = (engine.localization.getCountriesList(accountManager: accountManager, langCode: nil)
|> deliverOnMainQueue).start(next: { countries in
Expand All @@ -94,7 +145,9 @@ public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccoun
}
}
}
countryCodesByPrefix = countriesByPrefix
let (tsupportCountries, tsupportCountriesByPrefix) = applyTSupportCountry(countries: countryCodes, countriesByPrefix: countriesByPrefix)
countryCodes = tsupportCountries
countryCodesByPrefix = tsupportCountriesByPrefix

Queue.mainQueue().async {
completion()
Expand All @@ -119,7 +172,9 @@ public func loadServerCountryCodes(accountManager: AccountManager<TelegramAccoun
}
}
}
countryCodesByPrefix = countriesByPrefix
let (tsupportCountries, tsupportCountriesByPrefix) = applyTSupportCountry(countries: countryCodes, countriesByPrefix: countriesByPrefix)
countryCodes = tsupportCountries
countryCodesByPrefix = tsupportCountriesByPrefix
Queue.mainQueue().async {
completion()
}
Expand Down Expand Up @@ -204,8 +259,9 @@ public final class AuthorizationSequenceCountrySelectionController: ViewControll
}

public static func setupCountryCodes(countries: [Country], codesByPrefix: [String: (Country, Country.CountryCode)]) {
countryCodes = countries
countryCodesByPrefix = codesByPrefix
let (tsupportCountries, tsupportCountriesByPrefix) = applyTSupportCountry(countries: countries, countriesByPrefix: codesByPrefix)
countryCodes = tsupportCountries
countryCodesByPrefix = tsupportCountriesByPrefix
}

public static func lookupCountryNameById(_ id: String, strings: PresentationStrings) -> String? {
Expand Down
9 changes: 3 additions & 6 deletions submodules/TelegramCore/Sources/Account/Account.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,9 @@ public class UnauthorizedAccount {
}

let user = TelegramUser(user: apiUser)
var isSupportUser = false
if let phone = user.phone, phone.hasPrefix("42"), phone.count <= 5 {
isSupportUser = true
}
let isSupportUser = SupportAccount.isSupportPhoneNumber(user.phone)
let state = AuthorizedAccountState(isTestingEnvironment: testingEnvironment, masterDatacenterId: masterDatacenterId, peerId: user.id, state: nil, invalidatedChannels: [])
initializedAppSettingsAfterLogin(transaction: transaction, appVersion: networkArguments.appVersion, syncContacts: syncContacts)
initializedAppSettingsAfterLogin(transaction: transaction, appVersion: networkArguments.appVersion, syncContacts: syncContacts, isSupportUser: isSupportUser)
transaction.setState(state)
return accountManager.transaction { [weak self] transaction -> SendAuthorizationCodeResult in
if let self {
Expand Down Expand Up @@ -1272,7 +1269,7 @@ public class Account {

self.stateManager = AccountStateManager(accountPeerId: self.peerId, accountManager: accountManager, postbox: self.postbox, network: self.network, callSessionManager: self.callSessionManager, addIsContactUpdates: { [weak self] updates in
self?.contactSyncManager?.addIsContactUpdates(updates)
}, shouldKeepOnlinePresence: self.shouldKeepOnlinePresence.get(), peerInputActivityManager: self.peerInputActivityManager, auxiliaryMethods: auxiliaryMethods)
}, shouldKeepOnlinePresence: self.shouldKeepOnlinePresence.get(), peerInputActivityManager: self.peerInputActivityManager, auxiliaryMethods: auxiliaryMethods, isSupportUser: isSupportUser)

self.viewTracker = AccountViewTracker(account: self)
self.viewTracker.resetPeerHoleManagement = { [weak self] peerId in
Expand Down
38 changes: 38 additions & 0 deletions submodules/TelegramCore/Sources/Account/SupportAccountPhone.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// Identification of TSupport volunteer accounts by phone number.
///
/// A support account is an ordinary Telegram account that authorised with a support phone
/// number. Support numbers are `+42` followed by a region code and the volunteer's own
/// number, e.g. `+42490918884564680` — region `490` (India), volunteer `918884564680`.
///
/// Every allocated support region code begins `424`, and `424` is unassigned in E.164. The
/// neighbouring assigned codes are `420` (Czech Republic), `421` (Slovakia) and `423`
/// (Liechtenstein), so matching on `42` alone would classify real users of those countries
/// as support accounts.
public enum SupportAccount {
/// Dialing prefix shared by every support region code.
///
/// Deliberately `424` and not `42`. Deliberately a prefix rather than an enumeration of
/// the known region codes, so that region codes allocated later are recognised without
/// an app update.
public static let phoneNumberPrefix = "424"

/// Whether `phone` belongs to a support account.
///
/// Returns `false` for `nil` — an absent phone number means *unknown*, not *not a
/// support account*. Callers that revoke support status must treat the two differently.
///
/// Length is not constrained: the server may report either the full support number or a
/// shortened form, and both must match.
public static func isSupportPhoneNumber(_ phone: String?) -> Bool {
guard let phone else {
return false
}
return normalized(phone).hasPrefix(phoneNumberPrefix)
}

private static func normalized(_ phone: String) -> String {
return String(phone.filter { $0.isNumber })
}
}
54 changes: 54 additions & 0 deletions submodules/TelegramCore/Sources/Account/SupportAccountRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation
import SwiftSignalKit

public extension SupportAccount {
/// Whether an account record is marked as a support account.
static func isSupportUser(attributes: [TelegramAccountRecordAttribute]) -> Bool {
return attributes.contains { attribute in
if case .supportUserInfo = attribute {
return true
}
return false
}
}

/// Reconciles the persisted `.supportUserInfo` attribute with `isSupportUser`.
///
/// The attribute is what makes support status readable *synchronously* at launch, before
/// the `Account` object exists. Detection at authorisation is the primary writer; this is
/// the safety net for accounts that authorised before support detection existed, or by a
/// route that could not see a phone number.
///
/// A no-op when the record already agrees, so it is safe to call on every resolution.
static func updateAttribute(
accountManager: AccountManager<TelegramAccountManagerTypes>,
id: AccountRecordId,
isSupportUser: Bool
) -> Signal<Never, NoError> {
return accountManager.transaction { transaction -> Void in
transaction.updateRecord(id, { current in
guard let current else {
return nil
}
if self.isSupportUser(attributes: current.attributes) == isSupportUser {
return current
}
var attributes = current.attributes.filter { attribute in
if case .supportUserInfo = attribute {
return false
}
return true
}
if isSupportUser {
attributes.append(.supportUserInfo(AccountSupportUserInfo()))
}
return AccountRecord(
id: current.id,
attributes: attributes,
temporarySessionId: current.temporarySessionId
)
})
}
|> ignoreValues
}
}
Loading