Skip to content

Support-account mode for Telegram support volunteers - #2332

Open
nisarul wants to merge 6 commits into
TelegramMessenger:masterfrom
nisarul:tsupport-pr
Open

nisarul wants to merge 6 commits into
TelegramMessenger:masterfrom
nisarul:tsupport-pr

Conversation

@nisarul

@nisarul nisarul commented Sep 11, 2026

Copy link
Copy Markdown

Adds a support-account mode for Telegram support volunteers — the people who answer the queue generated when a user taps Ask a Question. Equivalents already exist for Android and Web; this is the iOS counterpart.

A volunteer is not a distinct kind of user to the server: they are an ordinary account that authorised with a support phone number (+42 + region code + their own number). Everything here keys off that single fact.

Draft. The changes build and install cleanly, and the support-side behaviour has been exercised on a device. What has not been systematically verified is the no-op path for regular accounts — see Testing below. Opening this early to check the approach is one you'd want before investing further.

Why much of this is re-enabling rather than adding

The tree already contains most of the plumbing — AccountSupportUserInfo, Account.isSupportUser, and read-suppression guards in ChatHistoryListNode — but it is disabled and its detection is incorrect. Where possible this corrects and enables the existing mechanism rather than introducing a parallel one, to keep the diff small and future merges cheap.

Three separate features turned out to be switched off behind an always-false !"".isEmpty condition:

Location Effect
SharedAccountContext Account.isSupportUser pinned to false in the main app
AccountStateManager ptsTotalLimit never sent, so large update backlogs are always replayed
ChatControllerOpenAttachmentMenu (unrelated, left alone)

Happy to drop any of those re-enablements if they were disabled deliberately — that history isn't visible from the outside.

What changed

Detection. A single predicate replaces nine inline copies. Those copies had drifted into two variants: two tested hasPrefix("42") && count <= 5, which rejects real support numbers; the other five tested hasPrefix("42") with no length bound, which matches Czech (+420) and Slovak (+421) numbers. The predicate now tests the 424 prefix — unassigned in E.164 — with no length constraint. AuthTransfer previously hardcoded false, so QR sign-in silently produced a normal account.

Read state. For support accounts, read state moves only when the volunteer replies or explicitly marks a chat read — never as a side effect of opening one. History read position was already guarded; personal mentions and reactions were not, on either of their two routes (immediate, and the deferred replay that drains when a chat becomes readable).

Composition. Voice/video recording, location, polls, contacts, to-do, audio attachments, attach-menu bots and gift sending are removed; Gallery and File remain, since screenshots and logs are the substance of a support reply. Story posting is blocked at openStoryCamera (seven call sites reach it). Group and channel creation are removed; the compose button and contact list are kept.

Identity. Settings rows irrelevant to a shared regional identity are hidden, and profile editing is blocked at both the affordance and the save handler. Peer photos outside the profile screen render as the letter monogram — as much a safety measure as a bandwidth one, since volunteers face a stream of strangers.

Quieting. Message notifications are disabled at login (per-account, so re-enableable in Settings). In-app banners and automatic media downloading are runtime overrides rather than stored preferences, because those settings live in account-manager shared data and are global — persisting them would affect the volunteer's other accounts. Server-provided suggestions are suppressed wholesale; one of them prompts the user to set a profile photo, which this change otherwise forbids.

Backlog. A support account accumulates thousands of pending updates while idle, and replaying them on launch is slow and pointless. Setting ptsTotalLimit asks the server for differenceTooLong instead, routing to the existing reset path. Unread state comes from the server, so no ticket is lost — only the local message cache, refilled per chat on demand.

Scope

Every change is gated on the support flag. For a regular account the intent is a byte-identical experience, and that constraint drove several decisions — notably using runtime overrides where the underlying setting is global.

Testing

  • Builds for simulator and device; installed and exercised on an iPhone with a support account.
  • Detection verified against a truth table: all known region codes match; +420, +421, +423, unassigned +422/+425, ordinary numbers, nil and "" do not.
  • Not yet done: a systematic pass confirming a regular account is unaffected. That is the main gap, and the reason this is a draft.

Notes for review

  • updates.getDifference has two call sites; only one handles differenceTooLong. The other treats it as preconditionFailure, so the limit must not be set there.
  • AvatarNode has two rendering paths and only one draws monograms; the chat list picks between them based on whether the peer has a photo.
  • ptsTotalLimit counts message-box events — messages, edits, deletions, consumption — not messages or chats.

nisarul and others added 5 commits September 12, 2026 00:23
Telegram support volunteers answer the queue generated when a user taps
"Ask a Question". They are ordinary accounts that authorised with a support
phone number (`+42` + region code + the volunteer's own number). This enables
the behaviour that queue work requires, scoped strictly to those accounts:
a regular account's experience is unchanged.

Upstream already contains most of the plumbing (`AccountSupportUserInfo`,
`Account.isSupportUser`, read-suppression guards in `ChatHistoryListNode`),
but it is disabled and its detection is wrong. This corrects and enables it
rather than introducing a parallel mechanism, to keep future upstream merges
cheap.

Detection
  Adds `SupportAccount.isSupportPhoneNumber` as the single predicate and
  routes all nine authorisation sites through it. The previous inline checks
  had drifted into two variants: two sites tested `hasPrefix("42") && count <= 5`,
  which rejects real 17-digit support numbers, while five tested `hasPrefix("42")`
  with no length bound, which matches Czech (+420) and Slovak (+421) numbers.
  The predicate now tests the `424` prefix with no length constraint — `424` is
  unassigned in E.164, so it cannot collide with a real country, and it accepts
  both the short and full forms the server may report.

  `AuthTransfer` previously hardcoded `isSupportUser: false`, so a volunteer
  signing in by QR code silently got a normal account; it now detects properly.

Enablement and reconciliation
  `SharedAccountContext` gated the persisted attribute behind `!"".isEmpty`,
  an always-false sentinel that pinned `Account.isSupportUser` to false in the
  main app while extensions read the real value. Removed.

  `AccountContext.isSupportUser` is seeded synchronously from the persisted
  account record — it must be readable before the first chat can open, or a
  cold start marks the queue read — then reconciled against the account peer's
  phone number, which also covers accounts authorised before this existed. An
  absent phone means *unknown*, never *not support*, so reconciliation cannot
  revoke the flag and silently re-enable read marking.

Read state
  Read state must move only when the volunteer replies or explicitly marks a
  chat read. History read position was already guarded. Personal mentions and
  reactions were not, on either of their two routes: the immediate one and the
  deferred replay that drains the scheduled sets once a chat becomes readable.
  Both now funnel through `markMentionsSeen` / `markReactionsAndVotesSeen`, so
  the deferred path cannot clear a badge that the immediate path suppressed.

  Chat-list "Mark as Read", Siri's `INSetMessageAttributeIntent` and inline
  push replies remain unguarded by design: they are explicit user intent.

Login
  Registers a hidden `+42` / `TS` "TSupport" country so the code resolves while
  typing without adding a fake country to the picker every user browses. The
  entry is reapplied at all four sites that replace the country list, since an
  entry present only in `PhoneCountries.txt` is discarded once the server list
  loads. It also claims every `424` prefix key, because the server list ships
  Telegram's own test country "Y-land" on `+42` under longer sub-prefix keys
  and prefix matching is longest-first.

Restrictions
  Hides settings rows that are meaningless for a shared support identity
  (emoji status, profile colour, profile photo, username, Premium, Stars,
  My TON, Business, Send Gift, Passport, Apple Watch, Recent Calls) and blocks
  profile editing — name, bio, username and avatar — at both the affordance and
  the save handler. Avatar blocking is scoped to the account's own peer so that
  group avatars and custom contact photos, which share the same entry point,
  keep working.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Support volunteers answer tickets with text, screenshots and files. This
removes the composition affordances that are not part of that job. Sending
only — receiving, playback and viewing are untouched throughout, since a
volunteer must still be able to open whatever a user sends them to answer
the ticket. Regular accounts are unaffected.

Input panel
  Hides the voice / round-video recording button. Both share a single button
  and a single visibility path, so they cannot be suppressed independently;
  both are blocked, so this is moot.

  The right-hand slot is shared between the mic (empty field) and Send
  (non-empty), so hiding the mic would leave a visible gap. Rather than add a
  branch, this reuses `keepSendButtonEnabled` — the existing "show Send
  without text" mechanism — so Send occupies the slot through an already
  exercised path and the shared layout code is untouched.

Attachment menu
  Reduced to Gallery and File; drops location, poll, contact, to-do and audio
  (a music-file attachment, distinct from the mic button), and skips the
  dynamically injected attach-menu bots. The fork's rich-text attachment
  picker is reduced likewise, since it independently offered audio and
  location. Gallery and File are load-bearing: screenshots and logs are the
  substance of a support reply.

Gifts
  `.gift` is not part of the attachment menu, so the reachable gift-sending
  surfaces in chat are the message context-menu action and the gift button in
  the compose surface. Both are suppressed. The Stars and My TON settings rows
  were already hidden.

Stories
  Posting is blocked; viewing is not. Guarded at `openStoryCamera` rather than
  at each caller, because seven call sites reach it — camera button, pull
  gesture and deep links — and at the "Post Story" profile button.

Groups and channels
  Removes New Group and New Channel from the compose menu, and the equivalent
  entries in the community peer-selection screen. The compose button, the
  contact list and New Contact are deliberately kept: volunteers legitimately
  start one-to-one chats, including to reach other volunteers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three further adjustments to keep a support account focused on the ticket
queue. As before, every change is gated on the support flag; a regular
account is unaffected.

Notifications
  Message notifications are disabled at login. These are per-account
  settings, so the volunteer's other accounts keep their own configuration,
  and because the values are stored rather than forced the volunteer can
  re-enable them in Settings.

  They are written to `toBeSynchronized`, not only `remote`: Telegram holds
  notification settings server-side, so a local-only value is silently
  overwritten by the next sync — which would quietly switch notifications
  back on. The trade-off is that the change propagates to the volunteer's
  other clients for this same account.

  The in-app banner, sound and vibration are suppressed separately, at the
  point of presentation. `InAppNotificationSettings` lives in account-manager
  shared data and is global to the app, so persisting a change there would
  silence the volunteer's personal account too. A runtime check on the
  support path is the only correctly scoped option; the cost is that in-app
  banners cannot be re-enabled from Settings for a support account.

Server-provided suggestions
  All of them are suppressed, not only "add your birthday". None applies to
  a shared regional identity, and one — the profile-photo prompt — actively
  contradicted the profile-editing restriction by asking the volunteer to
  set a photo the app then refuses to let them change. Filtering in
  `_internal_getServerProvidedSuggestions` rather than at each presentation
  site also excludes suggestions the server adds in future.

Peer photos
  Outside the profile screen, peer photos are replaced by the letter
  monogram Telegram already shows for peers without one — so chat list, chat
  title bar, search and contacts render initials, and layout is unchanged.
  Photos still load when the volunteer opens a profile, which uses a
  separate component.

  This needed three guards, not one: `setPeer`, `setPeerV2` (which resolves
  the photo independently) and the `DirectMediaImageCache` fast path, which
  reads the peer's image directly and would otherwise have bypassed both.

  The motivation is as much safety as bandwidth: volunteers see a stream of
  strangers, and unsolicited profile imagery is a real exposure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A support account accumulates thousands of pending updates while unused.
On launch the client replays the whole gap through `updates.getDifference`,
which is slow and largely pointless: the events are for tickets the
volunteer will never scroll to, and the chat list only ever renders the
most recent chats regardless.

`updates.getDifference` accepts `ptsTotalLimit`, which asks the server to
answer `differenceTooLong` rather than send a backlog larger than the given
size. The existing handler then runs `_internal_resetAccountState`: jump to
the server's current state and re-fetch the top chats. Upstream already
implements all of this and disables it — the limit sits behind an
always-false condition and is cleared unconditionally two lines later. This
enables it for support accounts only, at upstream's own value of 1000.

Nothing is lost from the volunteer's queue. `resetAccountState` takes read
state and unread counts from the server, so every ticket needing a reply
still shows as unread; only the local message cache is dropped, and it is
refilled per chat on demand. The visible cost is that opening an older chat
re-fetches its recent history over the network instead of reading from disk.

The limit counts message-box *events* — new messages, edits, deletions and
mention/reaction consumption — not messages or chats, so it trips somewhat
before 1000 literal new messages.

Only cold start and catch-up are affected. During an active session updates
arrive through the update loop and never reach `getDifference`.

Note there are two `getDifference` call sites and only this one handles the
reset; the other treats `differenceTooLong` as a `preconditionFailure`, so
the limit must not be set there.

Also logs the reset, which was previously silent — without it there is no
way to tell whether a fast launch came from this path or from something
else.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes found by running the previous build on a support account, plus one
new restriction.

Avatars rendered blank rather than showing the letter monogram, but only
for peers that actually have a photo. The chat list picks between two
rendering paths depending on exactly that (`ChatListItem`): peers with a
photo go to `setPeerV2`, an image-only fast path with no monogram drawing
at all, and peers without one go to `setPeer`, which draws letters. The
previous change suppressed the image inside `setPeerV2`, leaving nothing to
render. `setPeerV2` now delegates to `setPeer` for support accounts, so the
monogram path is used everywhere — this also covers avatars in message
bubbles, forward headers and date headers, not just the chat list.

Animated and video avatars continued to play in the chat list: they are a
separate `AvatarVideoNode` layered over the avatar, which was never guarded.
Story rings were likewise still drawn; volunteers neither post nor browse
stories from the queue, so the ring is noise on every row.

The voice-recording tooltip still appeared even though the button is hidden,
because it is triggered from interface state rather than from the button.

Automatic media downloading is now off for support accounts, which receive a
high volume of unsolicited media from strangers. As with in-app
notifications, the stored settings are shared across every account in the
app, so this is a runtime override rather than a written preference —
persisting it would disable downloading on the volunteer's other accounts.

That override has to be applied in three places, not one: the chat
controller's initial value, the subscription that tracks later changes to
the shared settings (which would otherwise immediately undo it), and the
prefetch manager, which would otherwise fetch the same media ahead of time.
Stickers still download; `shouldDownloadMediaAutomatically` short-circuits
for them and they are needed to render messages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@nisarul
nisarul marked this pull request as ready for review September 11, 2026 19:04
Blocking profile editing also removed the only route to signing out: upstream
places Log Out in the editing view (`settingsEditingItems`) rather than the
main settings list, so hiding the former took the latter with it. Support
accounts had no way to log out at all.

Adds a logout section to the main settings list, shown only for support
accounts, leaving edit mode blocked.

Audited the rest of the editing view for the same problem. It also holds the
name, bio and birthday fields, which are meant to be unavailable, and "Add
Another Account", which already appears in the main list. Log Out was the
only unintended loss.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants