Skip to content

Commit 6ad963e

Browse files
author
isaac
committed
Merge branch 'master' into beta
2 parents 6d37e50 + 4643a28 commit 6ad963e

152 files changed

Lines changed: 9576 additions & 1771 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,4 @@ xcode-files
8383
/buildbox/*
8484
**/node_modules/
8585
tools/sim-watcher/dist/
86+
.mcp.json

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ The sim ignores code signing, so the unsigned `Telegram_archive-root` bundle run
6969

7070
## RichTextEditor editor & the `ChatInputContent` composer
7171

72-
A from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`) is the default chat composer (the `forceLegacyTextInput` experimental flag opts back out to the legacy input), and `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).
72+
A from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`) is the native chat-composer backend — by default a **dual-field switch** (the composer uses the legacy input and latches to the native editor only when content becomes legacy-non-representable); the `forceNewTextInput` experimental flag (Debug Settings ▸ "Force Text Field v2") forces always-native. (This inverted the earlier default+`forceLegacyTextInput`-opt-out scheme.) `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).
7373

7474
## Embedded watch app (`Telegram/WatchApp`)
7575

Telegram/Telegram-iOS/en.lproj/Localizable.strings

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5303,6 +5303,8 @@ Sorry for the inconvenience.";
53035303

53045304
"PeerInfo.BioExpand" = "more";
53055305
"PeerInfo.HiddenCommunityStatus" = "Hidden • %@";
5306+
"PeerInfo.Community_1" = "Community with %@ chat";
5307+
"PeerInfo.Community_any" = "Community with %@ chats";
53065308
"PeerInfo.Community.RemoveBot" = "Remove Bot from Community";
53075309
"PeerInfo.Community.AddBot" = "Add Bot to a Community";
53085310
"PeerInfo.Community.BotInfo" = "Make your bot part of a community with multiple related chats";
@@ -16430,6 +16432,7 @@ Error: %8$@";
1643016432
"Community.View.SectionVisible" = "CHATS YOU CAN VIEW";
1643116433
"Community.View.SectionRequestable" = "CHATS YOU CAN REQUEST TO JOIN";
1643216434
"Community.View.SectionHidden" = "HIDDEN CHATS";
16435+
"Community.View.HiddenChatToast" = "You can't join or view this chat until an admin invites you.";
1643316436
"Community.View.PendingRequests" = "Pending Requests";
1643416437
"Community.View.PendingRequestHeader" = "PENDING REQUEST";
1643516438
"Community.View.ShowAsOneChat" = "Show as One Chat";
@@ -16440,6 +16443,8 @@ Error: %8$@";
1644016443
"Community.View.ChatsLimitReached" = "Sorry, this community has reached the maximum number of chats.";
1644116444
"Community.Request.UnknownRequester" = "Someone";
1644216445
"Community.Request.RequesterSuggestsGroup" = "%@ suggests this group:";
16446+
"Community.Request.RequesterSuggestsChannel" = "%@ suggests this channel:";
16447+
"Community.Request.RequesterSuggestsBot" = "%@ suggests this bot:";
1644316448
"Community.Request.PrivateStatus" = "visible only to its members";
1644416449
"Community.Request.Decline" = "Decline";
1644516450
"Community.Request.Add" = "Add";
@@ -16514,13 +16519,23 @@ Error: %8$@";
1651416519
"Community.Add.VisibilityHiddenInfo" = "Only invited members and community admins will see this chat.";
1651516520
"Community.Add.ActionAddToCommunity" = "Add to Community";
1651616521

16522+
"Community.Add.Confirm.TextGroup" = "If this group is approved by a community admin, its members will be able to join or request to join other chats in the community.";
16523+
"Community.Add.Confirm.TextChannel" = "If this channel is approved by a community admin, its subscribers will be able to join or request to join other chats in the community.";
16524+
"Community.Add.Confirm.TextBot" = "If this bot is approved by a community admin, community members will be able to see and use it.";
16525+
"Community.Add.Confirm.Add" = "Add";
16526+
16527+
"Community.AddChat.Title" = "Add a Chat";
16528+
"Community.AddChat.Description" = "Select a chat to add to this community";
16529+
1651716530
"Community.Chat.JoinToView" = "You need to join this group to view its messages.";
1651816531
"Community.PrivateChat.Info" = "This group is invite-only.\nOnly its members can view it.";
1651916532
"Community.PrivateChat.MessageOwner" = "Message Group Owner";
16533+
"Community.CommunityAdded.View" = "View";
1652016534

1652116535
"Privacy.GroupsAndChannels.InviteToCommunityError" = "Sorry, you cannot add %@ to the community because of %@'s privacy settings.";
1652216536

1652316537
"Notification.CommunityAddedGroup" = "%1$@ added this group to %2$@ community";
16538+
"Notification.CommunityAddedGroupUnknown" = "This group was added to %1$@ community";
1652416539
"Notification.CommunityAddedGroupYou" = "You added this group to %1$@ community";
1652516540
"Notification.CommunityRemovedGroup" = "%1$@ removed this group from a community";
1652616541
"Notification.CommunityRemovedGroupYou" = "You removed this group from a community";
@@ -16543,6 +16558,15 @@ Error: %8$@";
1654316558

1654416559
"Attachment.Article" = "Article";
1654516560
"RichText.AddMediaTitle" = "Add Media";
16561+
"RichText.RemoveFormattingTitle" = "Remove Formatting?";
16562+
"RichText.RemoveFormattingText" = "This message includes rich formatting, which requires Telegram Premium.";
16563+
"RichText.SubscribeToPremium" = "Subscribe to Premium";
16564+
"RichText.SendWithoutFormatting" = "Send Without Formatting";
16565+
"RichText.LinkTitle" = "Link";
16566+
"RichText.LinkText" = "Set a URL for the selected text";
16567+
"RichText.LinkSet" = "Set";
16568+
"RichText.LinkRemove" = "Remove";
16569+
"RichText.PremiumToastText" = "Sending a message with this formatting will require [Telegram Premium]().";
1654616570
"RichText.TableMenu.Alignment" = "Alignment";
1654716571
"RichText.FormulaAdd" = "Add Formula";
1654816572
"RichText.FormulaEdit" = "Edit Formula";
@@ -16563,6 +16587,11 @@ Error: %8$@";
1656316587
"RichText.Menu.Table.Copy" = "Copy Table";
1656416588
"RichText.Menu.Table.ConvertToText" = "Convert to Text";
1656516589
"RichText.Menu.Table.Delete" = "Delete Table";
16590+
"RichText.Menu.Table.HighlightOn" = "Highlight";
16591+
"RichText.Menu.Table.HighlightOff" = "Remove Highlight";
16592+
"RichText.PlaceholderBody" = "Type something...";
16593+
"RichText.PlaceholderQuote" = "Type a quote here";
16594+
"RichText.PlaceholderCode" = "Type code here";
1656616595

1656716596
"TextProcessing.TitleAICompose" = "Create with AI";
1656816597
"TextProcessing.ActionAddToPage" = "Add to Page";

docs/instantpage-richtext.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,17 @@ Specs: [`2026-06-02-instantpage-v2-audio-design.md`](docs/superpowers/specs/2026
122122
composer (`ChatInputContentInstantPage`) and the article editor (`RichTextEditorMessageConversion`'s
123123
`InstantPageBuilder`) converters — the first editor/rich-message path to produce a `.collage` block
124124
(needed zero codec work; it was already first-class through Postbox/FlatBuffers/upload). A container of
125-
exactly 1 item still sends the plain `.image`/`.video` block, byte-identical to before. `.slideshow`
126-
remains produced only by real web Instant View articles.
125+
exactly 1 item still sends the plain `.image`/`.video` block, byte-identical to before.
126+
127+
**`.slideshow` is now editor-produced too (added 2026-07-17).** A multi-media container carries a
128+
`displayMode` (`.mosaic` default / `.slideshow`; mirrored `MediaBlock.displayMode`
129+
`ChatInputMedia.displayMode`, Codable back-compat → `.mosaic`). Both forward converters branch on it:
130+
`.mosaic → .collage`, `.slideshow → .slideshow` (same inner `.image`/`.video` blocks); the reverse
131+
`chatInputBlocks(fromInstantPageBlocks:)` gained a `.slideshow` arm (sharing the collage arm's item
132+
helper) so the mode round-trips on edit. The mode is toggled in the **article editor** via a top-right
133+
button (mosaic↔slideshow); the composer stays mosaic-only. So `.slideshow` is now produced both by real
134+
web Instant View articles and by editor-authored slideshow albums. Design + plan:
135+
`docs/superpowers/{specs,plans}/2026-07-17-richtext-media-layout-toggle*`.
127136

128137
### Where things live
129138

@@ -438,6 +447,29 @@ Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](
438447
- **Forward parser keeps `[ ]` detection but routes to `checked`.** `markdownApplyTaskListMarker`/`markdownStrippingTaskListMarker`/`markdownTaskListMarker` still strip the marker from the item text; the state flows into `checked` while ordered items keep their real `"\(ordinal)"` number. The reverse converter emits lowercase `[x]` / `[ ]`, which the forward `hasPrefix` guards re-parse — that is the round-trip contract.
439448
- **The enum-arity change is compile-enforced.** Adding the third associated value broke every `.text`/`.blocks` construction/destructure; the full build is the completeness gate. Read-only consumers outside the core set exist (`BrowserInstantPageContent.swift`, `CachedFaqInstantPage.swift`) — grep `\.(text|blocks)\(` repo-wide when touching the enum again.
440449

450+
### Tap-to-toggle (editable rich messages)
451+
452+
Task-list checkboxes in a rendered rich message are **interactive when the message is editable**: tapping one flips it and persists the change by editing the message.
453+
454+
Where things live:
455+
456+
| File | Responsibility |
457+
|---|---|
458+
| `submodules/TelegramCore/Sources/InstantPageCheckboxToggle.swift` | Pure transform `InstantPage.togglingCheckbox(at: [Int], to: Bool) -> InstantPage` — rebuilds the block tree following a structural path and flips the target list item's `checked` (no-op on an unresolvable/non-checkbox path). |
459+
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageV2ListMarkerItem.checkboxPath: [Int]?`; a `pathPrefix: [Int]` threaded through `layoutBlockSequence`/`layoutBlock`/`layoutList`/`layoutDetails`/`layoutBlockQuote` stamps each `.checklist` marker with its path. |
460+
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | `InstantPageV2ListMarkerView` installs a tap gesture when `interactive`, flips its own `CheckNode` optimistically, and fires `onCheckboxTapped(path, newValue)`; `InstantPageV2View.checkboxTapped` routes it up (mirrors `detailsTapped`). |
461+
| `submodules/TelegramUI/Components/ChatControllerInteraction/Sources/ChatControllerInteraction.swift` | `canEditMessageRichText: (EngineRawMessage) -> Bool` (sync gate, mirrors `canSetupReply`) + `toggleMessageRichTextCheckbox: (EngineMessage.Id, [Int], Bool) -> Void` (the edit action). Both have no-op defaults so only the real chat wires them. |
462+
| `submodules/TelegramUI/Sources/ChatController.swift` | Implements both closures. The toggle looks up the message, re-checks `canEditMessage`, applies `togglingCheckbox`, and submits via `pendingUpdateMessageManager.add(text: "", richText:)` — the composer's rich-edit path (mirrors the native-todo `requestToggleTodoMessageItem`). |
463+
| `submodules/TelegramUI/Components/Chat/.../ChatMessageRichDataBubbleContentNode.swift` | `checkboxesInteractive(item:resolved:)` gate + sets `pageView.checkboxTapped` per apply. |
464+
465+
Non-obvious invariants:
466+
467+
- **Checkbox identity is a structural `[Int]` path**, not an ordinal: each element indexes the current container's children — block-array index at page/`.details`/`.blockQuote`/list-item-`.blocks` levels; item index at `.list` level. The layout stamping (`InstantPageV2Layout`) and the toggle walker (`InstantPageCheckboxToggle`) MUST keep identical semantics — they were built and reviewed as a matched pair. Decoupled from `<details>` expand/collapse state.
468+
- **The path is absolute-from-root only because every `layoutBlockSequence` that can reach a list is entered with a correct prefix.** The two secondary `layoutBlockSequence` call sites (table cells, hard-coded `[.paragraph]`; and the details title) contain no lists, so no checkbox is produced there; a `kind != .cell` guard in `layoutList` is belt-and-suspenders against future misrouting.
469+
- **The toggle applies to `attribute.instantPage`, so taps must only ever fire against that page.** The bubble's `checkboxesInteractive` gate enforces this: interactive only when `resolved.key` is `.original` AND `resolved.instantPage === attribute.instantPage` (class identity — excludes the show-more `fullInstantPage` rendering, which shares the `.original` key but is a different `InstantPage` object) AND `canEditMessageRichText(item.message)`. Translations (`.translated`) and in-flight pending edits (`.pendingEdit`) are inert. Non-editable messages (incoming, past the edit window) are inert — and AI-streamed rich messages are incoming, hence never tappable.
470+
- **Optimistic flip, model supersedes.** The marker view flips its own `CheckNode` on tap; the pending/edited attribute re-render then supersedes (or, on failure, reverts, since the model was unchanged). Known minor edges of this state-free optimism: (1) an unrelated `update()` before the pending edit lands rebuilds the marker from the old `checked`, briefly reverting the visual; (2) a rapid double-tap re-reads the still-stale `checked` and re-sends the same value rather than toggling back — the pending-edit manager coalesces, so the net state is consistent, just not a double-toggle. Both are acceptable given the edit lands promptly.
471+
- **Gating uses closures because `canEditMessage` is `internal` to the `TelegramUI` module** and the rich-data bubble lives in a separate component module that cannot call it. The two closures bridge the boundary; **their init-parameter order must match the `ChatController` call-site order** (Swift requirement) — they sit between `displayTodoToggleUnavailable` and `openStarsPurchase`.
472+
441473
## InstantPageBlock.blockQuote nested blocks
442474

443475
`InstantPageBlock.blockQuote` carries `(blocks: [InstantPageBlock], caption: RichText)` — a sequence of nested page blocks (paragraphs, headings, lists, code, even nested quotes), not the legacy text-only payload. `.pullQuote` is unchanged (still `(text: RichText, caption: RichText)`; the TL API has no `pullQuoteBlocks` constructor).

docs/richtext-composer.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ send-options preview and the attachment-menu rich-editor send — see §5).
115115
precedent — one value seen by undo/drafts/send/observers). Thin converters:
116116
`ChatRichTextEditorComposer/Sources/ComposerExpandedEditorBridge.swift`. Custom-emoji **files** (`[Int64:
117117
TelegramMediaFile]`) and media are threaded both ways (a fileId-only emoji ref renders blank).
118+
- **OUT back-fills emoji files the composer content only holds by fileId.** A `ChatInputContent` custom-emoji
119+
run may carry the fileId **without** the `TelegramMediaFile` attached; the plain `documentMediaAndEmoji(...)`
120+
then returns an `emojiFiles` map missing those ids, seeding the editor's emoji store (and, on the initial
121+
`.edit`/`.standalone` seed, the keyboard's file set) empty — so the emoji renders blank and its file is dropped
122+
on collapse-back / send. The OUT path therefore uses the **async** `documentMediaAndEmojiAsync(engine:…)`
123+
(awaited inside `openExpandedInput`'s `Task { @MainActor }`), which resolves any fileId with no inline file from
124+
the **local** sticker cache (`engine.stickers.resolveInlineStickersLocal`, a postbox `getMedia` over
125+
`Namespaces.Media.CloudFile`). Local-only is deliberate — composer emoji are always already cached, so no
126+
network round-trip stalls the expand; a fileId absent even locally simply stays unresolved (renders blank, as
127+
before). Persisted **drafts** need no resolution (their `emojiFiles` are stored/decoded whole in
128+
`RichTextDraft`); the in-editor AI-insert paths still use the sync `documentMediaAndEmoji` (their
129+
completion closures are synchronous, and AI-generated content rarely carries custom emoji).
118130

119131
> **Invariant — the cycle constraint.** `InstantPageUI` transitively deps `ChatTextInputPanelNode`, so the
120132
> panel/node **cannot** dep `InstantPageUI` / `RichTextEditorMediaView`. `TelegramUI` (above the cycle) builds
@@ -194,10 +206,17 @@ All inline / structural features round-trip losslessly through the native compos
194206
same one grouped album messages use), with per-cell view reuse keyed by media identity so surviving
195207
cells aren't rebuilt/re-fetched on an add/remove. A container of `items.count >= 2` sends as an
196208
InstantPage **`.collage`** rich message (see `instantpage-richtext.md`); `count == 1` still sends the
197-
byte-identical `.image`/`.video` block. **Authoring is split:** per-cell delete-one is wired in both
198-
hosts; "Add another photo/video" is wired in the **article editor**'s more-menu only — the composer's
199-
in-place Add is a deferred follow-up (the composer already renders/edits multi-media from sent albums
200-
and drafts). Design + plan: `docs/superpowers/{specs,plans}/2026-07-08-richtext-multi-media-container*`.
209+
byte-identical `.image`/`.video` block. **Layout mode (added 2026-07-17).** A `MediaBlock`/`ChatInputMedia`
210+
now carries a `displayMode` (`.mosaic` default / `.slideshow`, Codable back-compat → `.mosaic`); a
211+
`count >= 2` container in `.slideshow` mode renders in-editor as a swipeable carousel + paging dots and
212+
**sends as InstantPage `.slideshow`** instead of `.collage` (both forward converters branch on it; the
213+
reverse recovers it on edit). The toggle button that flips the mode is **article-editor only** (left of
214+
"+"); the composer stays mosaic-only. Design + plan:
215+
`docs/superpowers/{specs,plans}/2026-07-17-richtext-media-layout-toggle*`. **Authoring is split:** per-cell
216+
delete-one is wired in both hosts; "Add another photo/video" and the layout toggle are wired in the
217+
**article editor**'s chrome only — the composer's in-place Add is a deferred follow-up (the composer
218+
already renders/edits multi-media from sent albums and drafts). Design + plan:
219+
`docs/superpowers/{specs,plans}/2026-07-08-richtext-multi-media-container*`.
201220
**Media spoilers (added 2026-07-08).** A photo/video can be marked a Telegram-style spoiler (dust-covered
202221
until tapped), per **item**: `MediaItem.isSpoiler` (editor Core) ↔ `ChatInputMediaItem.isSpoiler`
203222
(both additive, optional-Codable, absent ⇒ `false`, so all existing docs/drafts/messages decode

0 commit comments

Comments
 (0)