diff --git a/docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md b/docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md new file mode 100644 index 0000000000..51fe6c30a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md @@ -0,0 +1,1317 @@ +# InstantPage list checkboxes (task-list style) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `InstantPageListItem` a first-class `checked: Bool?` so task-list checkboxes (`- [ ]` / `- [x]`) parse, serialize (Postbox + FlatBuffers), transmit (native API flags), render (real V2 CheckNode artwork), and survive the edit round-trip in rich-data message bubbles. + +**Architecture:** Add a third associated value `Bool?` to `InstantPageListItem.text` / `.blocks`, orthogonal to the existing `num` string, and delete the prior sentinel-in-`num` hack. Transmission reads/writes the `checkbox`=flags.0 / `checked`=flags.1 bits that already exist on all four API list-item constructors. Display reads `item.checked`; the V2 marker hosts a `CheckNode`. + +**Tech Stack:** Swift, Bazel (`Make.py`), Postbox coding, FlatBuffers (`flatc` genrule), Telegram `Api.*` TL types, `CheckNode` (ASDisplayNode). + +--- + +## Critical execution notes + +- **No unit tests exist in this project.** Verification is a full Bazel build plus a manual app round-trip (Task 10 / Task 11). Do not invent a test harness. +- **The enum change is source-breaking across modules.** TelegramCore will not compile until every `.text(...)` / `.blocks(...)` site (Tasks 1–9) is updated. **Do not attempt a build until Task 10.** Per-task subagents make edits only; the controller runs the single build-to-green at Task 10 (the full Bazel build must be driven by the controller with `run_in_background`, not by a subagent — backgrounded subagent builds get torn down on yield). +- **Commit once, at the end** (Task 11), after the build is green and verification passes — intermediate states don't compile. This matches the repo's feature-level commit style. +- **Build command** (from repo root, Task 10): + ```sh + source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ + --configuration=debug_sim_arm64 --continueOnError + ``` + +## Tri-state convention (used everywhere) + +`checked: Bool?` maps to a tri-state integer for both Postbox and FlatBuffers: + +``` +0 → nil (not a checkbox item) +1 → false (checkbox, unchecked) +2 → true (checkbox, checked) +``` + +Helper used in serialization tasks (define locally where needed): +- encode: `nil → 0, false → 1, true → 2` +- decode: `0 → nil, 1 → false, 2 → true` + +--- + +## Task 1: Data model — enum, FlatBuffers schema, Postbox, Equatable + +**Files:** +- Modify: `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` (enum ~1022, decodeListItems ~44, Postbox ~1027-1059, `==` ~1061-1082, FlatBuffers ~1084-1145) +- Modify: `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs:214-222` + +- [ ] **Step 1: Add the `checkState` field to both FlatBuffers tables** + +In `InstantPageBlock.fbs`, change: + +``` +table InstantPageListItem_Text { + text:RichText (id: 0, required); + number:string (id: 1); +} + +table InstantPageListItem_Blocks { + blocks:[InstantPageBlock] (id: 0, required); + number:string (id: 1); +} +``` + +to: + +``` +table InstantPageListItem_Text { + text:RichText (id: 0, required); + number:string (id: 1); + checkState:int32 (id: 2); +} + +table InstantPageListItem_Blocks { + blocks:[InstantPageBlock] (id: 0, required); + number:string (id: 1); + checkState:int32 (id: 2); +} +``` + +(int32 defaults to 0 → absent decodes as `nil`; the Bazel `flatc` genrule regenerates the Swift at build time. Do NOT edit the checked-in `*_generated.swift`.) + +- [ ] **Step 2: Add the associated value to the enum** + +In `SyncCore_InstantPage.swift`, change the enum (~line 1022): + +```swift +public indirect enum InstantPageListItem: PostboxCoding, Equatable { + case unknown + case text(RichText, String?) + case blocks([InstantPageBlock], String?) +``` + +to: + +```swift +public indirect enum InstantPageListItem: PostboxCoding, Equatable { + case unknown + case text(RichText, String?, Bool?) + case blocks([InstantPageBlock], String?, Bool?) +``` + +- [ ] **Step 3: Update `decodeListItems` construction (~line 44)** + +```swift + items.append(.text(item, nil)) +``` + +to: + +```swift + items.append(.text(item, nil, nil)) +``` + +- [ ] **Step 4: Update the Postbox `init(decoder:)` (~lines 1027-1036)** + +Replace: + +```swift + public init(decoder: PostboxDecoder) { + switch decoder.decodeInt32ForKey("r", orElse: 0) { + case InstantPageListItemType.text.rawValue: + self = .text(decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, decoder.decodeOptionalStringForKey("n")) + case InstantPageListItemType.blocks.rawValue: + self = .blocks(decoder.decodeObjectArrayWithDecoderForKey("b"), decoder.decodeOptionalStringForKey("n")) + default: + self = .unknown + } + } +``` + +with: + +```swift + public init(decoder: PostboxDecoder) { + switch decoder.decodeInt32ForKey("r", orElse: 0) { + case InstantPageListItemType.text.rawValue: + self = .text(decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, decoder.decodeOptionalStringForKey("n"), InstantPageListItem.checkedFromTriState(decoder.decodeInt32ForKey("ck", orElse: 0))) + case InstantPageListItemType.blocks.rawValue: + self = .blocks(decoder.decodeObjectArrayWithDecoderForKey("b"), decoder.decodeOptionalStringForKey("n"), InstantPageListItem.checkedFromTriState(decoder.decodeInt32ForKey("ck", orElse: 0))) + default: + self = .unknown + } + } +``` + +- [ ] **Step 5: Update the Postbox `encode` (~lines 1038-1059)** + +Replace: + +```swift + public func encode(_ encoder: PostboxEncoder) { + switch self { + case let .text(text, num): + encoder.encodeInt32(InstantPageListItemType.text.rawValue, forKey: "r") + encoder.encodeObject(text, forKey: "t") + if let num = num { + encoder.encodeString(num, forKey: "n") + } else { + encoder.encodeNil(forKey: "n") + } + case let .blocks(blocks, num): + encoder.encodeInt32(InstantPageListItemType.blocks.rawValue, forKey: "r") + encoder.encodeObjectArray(blocks, forKey: "b") + if let num = num { + encoder.encodeString(num, forKey: "n") + } else { + encoder.encodeNil(forKey: "n") + } + default: + break + } + } +``` + +with: + +```swift + public func encode(_ encoder: PostboxEncoder) { + switch self { + case let .text(text, num, checked): + encoder.encodeInt32(InstantPageListItemType.text.rawValue, forKey: "r") + encoder.encodeObject(text, forKey: "t") + if let num = num { + encoder.encodeString(num, forKey: "n") + } else { + encoder.encodeNil(forKey: "n") + } + if let triState = InstantPageListItem.triState(fromChecked: checked) { + encoder.encodeInt32(triState, forKey: "ck") + } else { + encoder.encodeNil(forKey: "ck") + } + case let .blocks(blocks, num, checked): + encoder.encodeInt32(InstantPageListItemType.blocks.rawValue, forKey: "r") + encoder.encodeObjectArray(blocks, forKey: "b") + if let num = num { + encoder.encodeString(num, forKey: "n") + } else { + encoder.encodeNil(forKey: "n") + } + if let triState = InstantPageListItem.triState(fromChecked: checked) { + encoder.encodeInt32(triState, forKey: "ck") + } else { + encoder.encodeNil(forKey: "ck") + } + default: + break + } + } +``` + +- [ ] **Step 6: Add the tri-state helpers as static methods on the enum** + +Immediately after the `encode(_:)` method (still inside `InstantPageListItem`), add: + +```swift + static func checkedFromTriState(_ value: Int32) -> Bool? { + switch value { + case 1: return false + case 2: return true + default: return nil + } + } + + /// Returns the persisted tri-state (1 = unchecked, 2 = checked) or nil when not a checkbox item. + static func triState(fromChecked checked: Bool?) -> Int32? { + switch checked { + case .some(false): return 1 + case .some(true): return 2 + case .none: return nil + } + } +``` + +- [ ] **Step 7: Update `==` (~lines 1061-1082)** + +Replace the `.text` and `.blocks` arms: + +```swift + case let .text(lhsText, lhsNum): + if case let .text(rhsText, rhsNum) = rhs, lhsText == rhsText, lhsNum == rhsNum { + return true + } else { + return false + } + case let .blocks(lhsBlocks, lhsNum): + if case let .blocks(rhsBlocks, rhsNum) = rhs, lhsBlocks == rhsBlocks, lhsNum == rhsNum { + return true + } else { + return false + } +``` + +with: + +```swift + case let .text(lhsText, lhsNum, lhsChecked): + if case let .text(rhsText, rhsNum, rhsChecked) = rhs, lhsText == rhsText, lhsNum == rhsNum, lhsChecked == rhsChecked { + return true + } else { + return false + } + case let .blocks(lhsBlocks, lhsNum, lhsChecked): + if case let .blocks(rhsBlocks, rhsNum, rhsChecked) = rhs, lhsBlocks == rhsBlocks, lhsNum == rhsNum, lhsChecked == rhsChecked { + return true + } else { + return false + } +``` + +- [ ] **Step 8: Update the FlatBuffers `init(flatBuffersObject:)` (~lines 1084-1105)** + +Replace the two value arms: + +```swift + self = .text(try RichText(flatBuffersObject: textValue.text), textValue.number) +``` + +with: + +```swift + self = .text(try RichText(flatBuffersObject: textValue.text), textValue.number, InstantPageListItem.checkedFromTriState(textValue.checkState)) +``` + +and: + +```swift + self = .blocks(blocks, blocksValue.number) +``` + +with: + +```swift + self = .blocks(blocks, blocksValue.number, InstantPageListItem.checkedFromTriState(blocksValue.checkState)) +``` + +- [ ] **Step 9: Update the FlatBuffers `encodeToFlatBuffers` (~lines 1107-1145)** + +Change the destructure of the two arms from `case let .text(text, number):` to `case let .text(text, number, checked):` and from `case let .blocks(blocks, number):` to `case let .blocks(blocks, number, checked):`. Then, after each existing `if let _ = number { ... add(number:) }` block (and before computing `offset`), add the checkState write: + +For the `.text` arm: + +```swift + if let triState = InstantPageListItem.triState(fromChecked: checked) { + TelegramCore_InstantPageListItem_Text.add(checkState: triState, &builder) + } +``` + +For the `.blocks` arm: + +```swift + if let triState = InstantPageListItem.triState(fromChecked: checked) { + TelegramCore_InstantPageListItem_Blocks.add(checkState: triState, &builder) + } +``` + +- [ ] **Step 10: Static self-check (no build yet)** + +Run: `grep -nE '\.(text|blocks)\(' submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift | grep -iE 'listitem|\.plain|decodeObjectArray|RichText\(flat|item, nil'` +Expected: every `InstantPageListItem` `.text(...)`/`.blocks(...)` now has three components. (Confirmed at build in Task 10.) + +--- + +## Task 2: API transmission — accessors + checkbox flag bits + +**Files:** +- Modify: `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` (`num` accessor ~16-27, `init(apiListItem:)` ~30-39, `init(apiListOrderedItem:)` ~41-50, `apiInputPageListItem()` ~52-61, `apiInputPageOrderedListItem()` ~63-88) + +- [ ] **Step 1: Update the `num` accessor and add a `checked` accessor (~lines 16-27)** + +Replace: + +```swift +public extension InstantPageListItem { + var num: String? { + switch self { + case let .text(_, num): + return num + case let .blocks(_, num): + return num + default: + return nil + } + } +} +``` + +with: + +```swift +public extension InstantPageListItem { + var num: String? { + switch self { + case let .text(_, num, _): + return num + case let .blocks(_, num, _): + return num + default: + return nil + } + } + + var checked: Bool? { + switch self { + case let .text(_, _, checked): + return checked + case let .blocks(_, _, checked): + return checked + default: + return nil + } + } +} +``` + +- [ ] **Step 2: Read checkbox flags on receive — `init(apiListItem:)` (~lines 30-39)** + +Replace: + +```swift + init(apiListItem: Api.PageListItem) { + switch apiListItem { + case let .pageListItemText(pageListItemTextData): + let text = pageListItemTextData.text + self = .text(RichText(apiText: text), nil) + case let .pageListItemBlocks(pageListItemBlocksData): + let blocks = pageListItemBlocksData.blocks + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), nil) + } + } +``` + +with: + +```swift + init(apiListItem: Api.PageListItem) { + switch apiListItem { + case let .pageListItemText(pageListItemTextData): + let text = pageListItemTextData.text + self = .text(RichText(apiText: text), nil, InstantPageListItem.checkedFromApiFlags(pageListItemTextData.flags)) + case let .pageListItemBlocks(pageListItemBlocksData): + let blocks = pageListItemBlocksData.blocks + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), nil, InstantPageListItem.checkedFromApiFlags(pageListItemBlocksData.flags)) + } + } +``` + +- [ ] **Step 3: Read checkbox flags on receive — `init(apiListOrderedItem:)` (~lines 41-50)** + +Replace: + +```swift + init(apiListOrderedItem: Api.PageListOrderedItem) { + switch apiListOrderedItem { + case let .pageListOrderedItemText(pageListOrderedItemTextData): + let (num, text) = (pageListOrderedItemTextData.num, pageListOrderedItemTextData.text) + self = .text(RichText(apiText: text), num) + case let .pageListOrderedItemBlocks(pageListOrderedItemBlocksData): + let (num, blocks) = (pageListOrderedItemBlocksData.num, pageListOrderedItemBlocksData.blocks) + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), num) + } + } +``` + +with: + +```swift + init(apiListOrderedItem: Api.PageListOrderedItem) { + switch apiListOrderedItem { + case let .pageListOrderedItemText(pageListOrderedItemTextData): + let (num, text) = (pageListOrderedItemTextData.num, pageListOrderedItemTextData.text) + self = .text(RichText(apiText: text), num, InstantPageListItem.checkedFromApiFlags(pageListOrderedItemTextData.flags)) + case let .pageListOrderedItemBlocks(pageListOrderedItemBlocksData): + let (num, blocks) = (pageListOrderedItemBlocksData.num, pageListOrderedItemBlocksData.blocks) + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), num, InstantPageListItem.checkedFromApiFlags(pageListOrderedItemBlocksData.flags)) + } + } +``` + +- [ ] **Step 4: Write checkbox flags on send — `apiInputPageListItem()` (~lines 52-61)** + +Replace: + +```swift + func apiInputPageListItem() -> Api.PageListItem { + switch self { + case let .text(value, _): + return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: 0, text: value.apiRichText())) + case let .blocks(blocks, _): + return .pageListItemBlocks(Api.PageListItem.Cons_pageListItemBlocks(flags: 0, blocks: blocks.compactMap { $0.apiInputBlock() })) + case .unknown: + return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")))) + } + } +``` + +with: + +```swift + func apiInputPageListItem() -> Api.PageListItem { + switch self { + case let .text(value, _, checked): + return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: InstantPageListItem.apiFlags(fromChecked: checked), text: value.apiRichText())) + case let .blocks(blocks, _, checked): + return .pageListItemBlocks(Api.PageListItem.Cons_pageListItemBlocks(flags: InstantPageListItem.apiFlags(fromChecked: checked), blocks: blocks.compactMap { $0.apiInputBlock() })) + case .unknown: + return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")))) + } + } +``` + +- [ ] **Step 5: Write checkbox flags on send — `apiInputPageOrderedListItem()` (~lines 63-88)** + +Replace: + +```swift + func apiInputPageOrderedListItem() -> Api.InputPageListOrderedItem { + switch self { + case let .text(value, num): + var flags: Int32 = 0 + + var inputNum: Int32? + if let num, let numValue = Int32(num) { + inputNum = numValue + flags |= (1 << 2) + } + + return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: flags, text: value.apiRichText(), value: inputNum, type: nil)) + case let .blocks(blocks, num): + var flags: Int32 = 0 + + var inputNum: Int32? + if let num, let numValue = Int32(num) { + inputNum = numValue + flags |= (1 << 2) + } + + return .inputPageListOrderedItemBlocks(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemBlocks(flags: flags, blocks: blocks.compactMap { $0.apiInputBlock() }, value: inputNum, type: nil)) + case .unknown: + return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")), value: nil, type: nil)) + } + } +``` + +with: + +```swift + func apiInputPageOrderedListItem() -> Api.InputPageListOrderedItem { + switch self { + case let .text(value, num, checked): + var flags: Int32 = InstantPageListItem.apiFlags(fromChecked: checked) + + var inputNum: Int32? + if let num, let numValue = Int32(num) { + inputNum = numValue + flags |= (1 << 2) + } + + return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: flags, text: value.apiRichText(), value: inputNum, type: nil)) + case let .blocks(blocks, num, checked): + var flags: Int32 = InstantPageListItem.apiFlags(fromChecked: checked) + + var inputNum: Int32? + if let num, let numValue = Int32(num) { + inputNum = numValue + flags |= (1 << 2) + } + + return .inputPageListOrderedItemBlocks(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemBlocks(flags: flags, blocks: blocks.compactMap { $0.apiInputBlock() }, value: inputNum, type: nil)) + case .unknown: + return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")), value: nil, type: nil)) + } + } +``` + +- [ ] **Step 6: Add the API-flag helpers** + +Add to the `public extension InstantPageListItem` block in this file (after the `checked` accessor from Step 1). `checkbox` = bit 0, `checked` = bit 1 (verified against the generated `Api.PageListItem` / `Api.PageListOrderedItem` / `Api.InputPageListOrderedItem`). + +```swift + static func checkedFromApiFlags(_ flags: Int32) -> Bool? { + guard (flags & (1 << 0)) != 0 else { + return nil + } + return (flags & (1 << 1)) != 0 + } + + static func apiFlags(fromChecked checked: Bool?) -> Int32 { + guard let checked else { + return 0 + } + var flags: Int32 = 1 << 0 + if checked { + flags |= (1 << 1) + } + return flags + } +``` + +- [ ] **Step 7: Static self-check** + +Run: `grep -nE 'flags: 0|apiFlags|checkedFromApiFlags' submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` +Expected: the two `.pageListItem*` send arms use `apiFlags(...)`; the two `.unknown` fallbacks keep `flags: 0`; both receive inits use `checkedFromApiFlags(...)`. + +--- + +## Task 3: Markdown forward parser — route into `checked`, drop the sentinel + +**Files:** +- Modify: `submodules/BrowserUI/Sources/BrowserMarkdown.swift` (sentinel consts ~23-24, `validate(listItem:)` ~203, `markdownListItems` ~1436-1475, `markdownTaskListNumber` ~1477-1484, `markdownFirstParagraphText` list arm ~2287-2292) + +- [ ] **Step 1: Remove the markdown sentinel constants (~lines 23-24)** + +Delete: + +```swift +private let markdownTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" +private let markdownTaskListCheckedNumber = "\u{001f}tg-md-task:checked" +``` + +- [ ] **Step 2: Update `validate(listItem:)` destructure (~line 203)** + +```swift + if case let .blocks(blocks, _) = listItem { +``` + +to: + +```swift + if case let .blocks(blocks, _, _) = listItem { +``` + +- [ ] **Step 3: Rewrite the item construction in `markdownListItems` (~lines 1449-1472)** + +Replace: + +```swift + let taskListState = markdownApplyTaskListMarker(to: &blocks) + let number: String? + if let taskListState { + number = markdownTaskListNumber(for: taskListState) + } else if ordered { + number = "\(ordinal)" + } else { + number = nil + } + if blocks.isEmpty { + if let number { + result.append(.text(.plain(" "), number)) + } + continue + } + if blocks.count == 1, case let .paragraph(text) = blocks[0] { + if number != nil && markdownIsWhitespaceOnly(text) { + result.append(.text(.plain(" "), number)) + } else { + result.append(.text(text, number)) + } + } else { + result.append(.blocks(blocks, number)) + } +``` + +with: + +```swift + let taskListState = markdownApplyTaskListMarker(to: &blocks) + let checked: Bool? + switch taskListState { + case .unchecked: + checked = false + case .checked: + checked = true + case nil: + checked = nil + } + let number: String? = ordered ? "\(ordinal)" : nil + if blocks.isEmpty { + if checked != nil || number != nil { + result.append(.text(.plain(" "), number, checked)) + } + continue + } + if blocks.count == 1, case let .paragraph(text) = blocks[0] { + if markdownIsWhitespaceOnly(text) && (checked != nil || number != nil) { + result.append(.text(.plain(" "), number, checked)) + } else { + result.append(.text(text, number, checked)) + } + } else { + result.append(.blocks(blocks, number, checked)) + } +``` + +(Note: task lists are unordered, so a task item normally has `number == nil`; an ordered task item now keeps its number *and* its checkbox.) + +- [ ] **Step 4: Delete the now-unused `markdownTaskListNumber` (~lines 1477-1484)** + +Delete: + +```swift +private func markdownTaskListNumber(for state: MarkdownTaskListState) -> String { + switch state { + case .unchecked: + return markdownTaskListUncheckedNumber + case .checked: + return markdownTaskListCheckedNumber + } +} +``` + +(Keep `markdownApplyTaskListMarker`, `markdownStrippingTaskListMarker`, `markdownTaskListMarker`, and the `MarkdownTaskListState` enum — they still detect the `[ ]`/`[x]` syntax.) + +- [ ] **Step 5: Update `markdownFirstParagraphText` list-item destructures (~lines 2288, 2292)** + +```swift + case let .text(text, _): +``` +to +```swift + case let .text(text, _, _): +``` +and +```swift + case let .blocks(blocks, _): +``` +to +```swift + case let .blocks(blocks, _, _): +``` + +- [ ] **Step 6: Static self-check** + +Run: `grep -n 'markdownTaskList\|tg-md-task' submodules/BrowserUI/Sources/BrowserMarkdown.swift` +Expected: no `markdownTaskListUncheckedNumber`/`CheckedNumber`/`markdownTaskListNumber`/`tg-md-task` references remain (only `markdownTaskListMarker` / `markdownApplyTaskListMarker` / `markdownStrippingTaskListMarker` / `MarkdownTaskListState`). + +--- + +## Task 4: BrowserReadability construction sites + +**Files:** +- Modify: `submodules/BrowserUI/Sources/BrowserReadability.swift` (~lines 613, 616, 623) + +- [ ] **Step 1: Update the two construction sites (~lines 613, 616)** + +```swift + items.append(.blocks(blocks, nil)) +``` +to +```swift + items.append(.blocks(blocks, nil, nil)) +``` +and +```swift + items.append(.text(trim(parseRichText(item, &media)), nil)) +``` +to +```swift + items.append(.text(trim(parseRichText(item, &media)), nil, nil)) +``` + +- [ ] **Step 2: Update the destructure (~line 623)** + +```swift + if case let .text(text, _) = item { +``` +to +```swift + if case let .text(text, _, _) = item { +``` + +- [ ] **Step 3: Static self-check** + +Run: `grep -nE '\.(text|blocks)\(' submodules/BrowserUI/Sources/BrowserReadability.swift | grep -iE 'items\.append|case let'` +Expected: all list-item `.text`/`.blocks` sites carry three components. + +--- + +## Task 5: Reverse markdown — emit `- [ ]` / `- [x]` for the edit round-trip + +**Files:** +- Modify: `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` (`markdownList` ~164-200) + +- [ ] **Step 1: Emit the task marker from `checked` (~lines 168-196)** + +Replace the body of the `for item in items` loop in `markdownList`: + +```swift + for item in items { + // The stored per-item marker string (`InstantPageListItem`'s `String?`) is + // intentionally ignored: ordered markers are regenerated from the running + // index (CommonMark renumbers anyway) and the unordered marker is fixed. + let marker = ordered ? "\(index). " : "- " + switch item { + case let .text(text, _): + lines.append("\(indentString)\(marker)\(markdownInline(from: text))") + case let .blocks(blocks, _): + var remainder = blocks + var markerLineText = "" + if case let .paragraph(text)? = remainder.first { + markerLineText = markdownInline(from: text) + remainder = Array(remainder.dropFirst()) + } + lines.append("\(indentString)\(marker)\(markerLineText)") + let childIndentString = String(repeating: " ", count: (indent + 1) * 2) + for block in remainder { + if case let .list(nestedItems, nestedOrdered) = block { + lines.append(markdownList(items: nestedItems, ordered: nestedOrdered, indent: indent + 1)) + } else if let rendered = markdownString(from: block) { + for line in rendered.split(separator: "\n", omittingEmptySubsequences: false) { + lines.append("\(childIndentString)\(line)") + } + } + } + case .unknown: + break + } + index += 1 + } +``` + +with: + +```swift + for item in items { + // Ordered markers are regenerated from the running index (CommonMark renumbers + // anyway); the unordered marker is fixed. A task-list `checked` state is emitted + // as a GitHub task marker so re-classification on save re-parses it as a checkbox. + let listMarker = ordered ? "\(index). " : "- " + let taskMarker: String + switch item.checked { + case .some(false): + taskMarker = "[ ] " + case .some(true): + taskMarker = "[x] " + case .none: + taskMarker = "" + } + let marker = "\(listMarker)\(taskMarker)" + switch item { + case let .text(text, _, _): + lines.append("\(indentString)\(marker)\(markdownInline(from: text))") + case let .blocks(blocks, _, _): + var remainder = blocks + var markerLineText = "" + if case let .paragraph(text)? = remainder.first { + markerLineText = markdownInline(from: text) + remainder = Array(remainder.dropFirst()) + } + lines.append("\(indentString)\(marker)\(markerLineText)") + let childIndentString = String(repeating: " ", count: (indent + 1) * 2) + for block in remainder { + if case let .list(nestedItems, nestedOrdered) = block { + lines.append(markdownList(items: nestedItems, ordered: nestedOrdered, indent: indent + 1)) + } else if let rendered = markdownString(from: block) { + for line in rendered.split(separator: "\n", omittingEmptySubsequences: false) { + lines.append("\(childIndentString)\(line)") + } + } + } + case .unknown: + break + } + index += 1 + } +``` + +(`item.checked` resolves via the accessor added in Task 2; both files are in modules that import TelegramCore.) + +- [ ] **Step 2: Static self-check** + +Run: `grep -n 'item.checked\|\[x\] \|\[ \] ' submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` +Expected: the `[ ] ` / `[x] ` task markers are emitted from `item.checked`. + +--- + +## Task 6: preview text — checkbox glyph, no sentinel leak + +**Files:** +- Modify: `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` (`previewText()` ~55-81) + +- [ ] **Step 1: Render a glyph for checkbox items (~lines 60-78)** + +Replace the `.text` and `.blocks` arms: + +```swift + case let .text(text, num): + if let num, !num.isEmpty { + return "\(num). \(text.previewText())" + } else { + return text.previewText() + } + case let .blocks(blocks, num): + var blocksText = "" + for block in blocks { + if !blocksText.isEmpty { + blocksText.append("\n") + } + blocksText.append(block.previewText()) + } + if let num { + return "\(num). \(blocksText)" + } else { + return blocksText + } +``` + +with: + +```swift + case let .text(text, num, checked): + let body = text.previewText() + if let checked { + return "\(checked ? "☑︎" : "☐") \(body)" + } else if let num, !num.isEmpty { + return "\(num). \(body)" + } else { + return body + } + case let .blocks(blocks, num, checked): + var blocksText = "" + for block in blocks { + if !blocksText.isEmpty { + blocksText.append("\n") + } + blocksText.append(block.previewText()) + } + if let checked { + return "\(checked ? "☑︎" : "☐") \(blocksText)" + } else if let num { + return "\(num). \(blocksText)" + } else { + return blocksText + } +``` + +- [ ] **Step 2: Static self-check** + +Run: `grep -nE '\.(text|blocks)\(' submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` +Expected: both arms destructure three components and branch on `checked`. + +--- + +## Task 7: V1 layout — read `checked`, remove the sentinel + +**Files:** +- Modify: `submodules/InstantPageUI/Sources/InstantPageLayout.swift` (sentinel ~134-147, list detection ~383, 392, empty-blocks ~435-436, destructures ~439, 469) + +- [ ] **Step 1: Remove the V1 sentinel constants and helper (~lines 134-147)** + +Delete: + +```swift +private let instantPageTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" +private let instantPageTaskListCheckedNumber = "\u{001f}tg-md-task:checked" +``` + +and the whole helper: + +```swift +private func instantPageTaskListMarkerState(_ number: String?) -> Bool? { + switch number { + case instantPageTaskListUncheckedNumber: + return false + case instantPageTaskListCheckedNumber: + return true + default: + return nil + } +} +``` + +Keep `private let instantPageChecklistMarkerSize = CGSize(width: 18.0, height: 18.0)`. + +- [ ] **Step 2: Replace the two `instantPageTaskListMarkerState(item.num)` reads (~lines 383, 392)** + +```swift + if instantPageTaskListMarkerState(item.num) != nil { +``` +to +```swift + if item.checked != nil { +``` +and +```swift + if let checked = instantPageTaskListMarkerState(item.num) { +``` +to +```swift + if let checked = item.checked { +``` + +- [ ] **Step 3: Update the empty-blocks substitution (~lines 435-436)** + +```swift + if case let .blocks(blocks, num) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num) + } +``` +to +```swift + if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num, checked) + } +``` + +- [ ] **Step 4: Update the two destructures (~lines 439, 469)** + +```swift + case let .text(text, _): +``` +to +```swift + case let .text(text, _, _): +``` +and +```swift + case let .blocks(blocks, _): +``` +to +```swift + case let .blocks(blocks, _, _): +``` + +- [ ] **Step 5: Static self-check** + +Run: `grep -n 'instantPageTaskListMarkerState\|tg-md-task\|item.checked' submodules/InstantPageUI/Sources/InstantPageLayout.swift` +Expected: no sentinel/helper references; list detection now reads `item.checked`. + +--- + +## Task 8: V2 layout — `checked` detection + carry checkbox colors to the marker + +**Files:** +- Modify: `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` (marker kind/item ~139-149, sentinel ~1999-2011, `layoutList` detection ~2027-2057, empty-blocks ~2112-2113, destructures ~2117, 2166, `.checklist` construction ~2057) + +- [ ] **Step 1: Add a checkbox-colors struct and extend the `.checklist` marker kind (~lines 139-149)** + +Replace: + +```swift +public enum InstantPageV2ListMarkerKind { + case bullet + case number(String) + case checklist(checked: Bool) +} +``` + +with: + +```swift +public struct InstantPageV2CheckboxColors { + public let background: UIColor + public let stroke: UIColor + public let border: UIColor + + public init(background: UIColor, stroke: UIColor, border: UIColor) { + self.background = background + self.stroke = stroke + self.border = border + } +} + +public enum InstantPageV2ListMarkerKind { + case bullet + case number(String) + case checklist(checked: Bool, colors: InstantPageV2CheckboxColors) +} +``` + +(The existing no-bind `case .checklist:` matches in `markerFrameFor` and the `firstBlockLineMidY` switch keep working — only the construction site and the renderer's binding need updating.) + +- [ ] **Step 2: Remove the V2 sentinel constants and helper (~lines 1999-2011)** + +Delete: + +```swift +private let instantPageTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" +private let instantPageTaskListCheckedNumber = "\u{001f}tg-md-task:checked" + +private func instantPageTaskListMarkerState(_ number: String?) -> Bool? { + switch number { + case instantPageTaskListUncheckedNumber: + return false + case instantPageTaskListCheckedNumber: + return true + default: + return nil + } +} +``` + +- [ ] **Step 3: Replace the `hasTaskMarkers` detection in `layoutList` (~lines 2027-2042)** + +Replace: + +```swift + if ordered { + for item in listItems { + if instantPageTaskListMarkerState(item.num) != nil { + hasTaskMarkers = true + } else if let num = item.num, !num.isEmpty { + hasNums = true + } + } + } else { + for item in listItems { + if instantPageTaskListMarkerState(item.num) != nil { + hasTaskMarkers = true + break + } + } + } +``` + +with: + +```swift + if ordered { + for item in listItems { + if item.checked != nil { + hasTaskMarkers = true + } else if let num = item.num, !num.isEmpty { + hasNums = true + } + } + } else { + for item in listItems { + if item.checked != nil { + hasTaskMarkers = true + break + } + } + } +``` + +- [ ] **Step 4: Replace the per-item marker descriptor build (~lines 2052-2057)** + +Replace: + +```swift + for (i, item) in listItems.enumerated() { + if let checked = instantPageTaskListMarkerState(item.num) { + if ordered { + maxIndexWidth = max(maxIndexWidth, checklistMarkerSize.width) + } + markerInfos.append(MarkerInfo(kind: .checklist(checked: checked), naturalWidth: checklistMarkerSize.width)) + } else if ordered { +``` + +with: + +```swift + let checkboxColors = InstantPageV2CheckboxColors( + background: context.theme.panelAccentColor, + stroke: context.theme.pageBackgroundColor, + border: context.theme.controlColor + ) + for (i, item) in listItems.enumerated() { + if let checked = item.checked { + if ordered { + maxIndexWidth = max(maxIndexWidth, checklistMarkerSize.width) + } + markerInfos.append(MarkerInfo(kind: .checklist(checked: checked, colors: checkboxColors), naturalWidth: checklistMarkerSize.width)) + } else if ordered { +``` + +- [ ] **Step 5: Update the empty-blocks substitution (~lines 2112-2113)** + +```swift + if case let .blocks(blocks, num) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num) + } +``` +to +```swift + if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num, checked) + } +``` + +- [ ] **Step 6: Update the two `layoutList` destructures (~lines 2117, 2166)** + +```swift + case let .text(text, _): +``` +to +```swift + case let .text(text, _, _): +``` +and +```swift + case let .blocks(blocks, _): +``` +to +```swift + case let .blocks(blocks, _, _): +``` + +(These are inside `layoutList`'s `switch effectiveItem`. Do NOT touch the unrelated `InstantPageV2LaidOutItem` `.text(...)` cases elsewhere in the file — those take a single `InstantPageV2TextItem` and are a different enum.) + +- [ ] **Step 7: Verify the theme color names** + +Run: `grep -nE 'var (panelAccentColor|pageBackgroundColor|controlColor)' submodules/InstantPageUI/Sources/InstantPageTheme.swift` +Expected: all three exist on `InstantPageTheme` (they back the V1 `instantPageChecklistMarkerTheme`). If a name differs, use the V1 names from `InstantPageChecklistMarkerItem.swift`'s `instantPageChecklistMarkerTheme`. + +- [ ] **Step 8: Static self-check** + +Run: `grep -n 'instantPageTaskListMarkerState\|tg-md-task\|item.checked\|\.checklist(' submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` +Expected: no sentinel/helper references; detection reads `item.checked`; `.checklist(checked:colors:)` at the construction site. + +--- + +## Task 9: V2 renderer — real CheckNode artwork + +**Files:** +- Modify: `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (imports ~1-15, `.checklist` case ~1362-1377) + +- [ ] **Step 1: Add the CheckNode import** + +After `import Display` (line 3), add: + +```swift +import CheckNode +``` + +(`//submodules/CheckNode:CheckNode` is already in `InstantPageUI/BUILD`.) + +- [ ] **Step 2: Replace the placeholder `.checklist` drawing (~lines 1362-1377)** + +Replace: + +```swift + case let .checklist(checked): + // V0 placeholder: simple square outline (unchecked) or filled square (checked). + // The existing V1 InstantPageChecklistMarkerItem artwork can be ported later if needed. + let outer = CALayer() + outer.borderColor = item.color.cgColor + outer.borderWidth = 1.0 + outer.cornerRadius = 3.0 + outer.frame = CGRect(origin: .zero, size: item.frame.size) + self.layer.addSublayer(outer) + if checked { + let fill = CALayer() + fill.backgroundColor = item.color.cgColor + fill.cornerRadius = 3.0 + fill.frame = CGRect(origin: .zero, size: item.frame.size).insetBy(dx: 2.0, dy: 2.0) + self.layer.addSublayer(fill) + } +``` + +with: + +```swift + case let .checklist(checked, colors): + let checkNodeTheme = CheckNodeTheme( + backgroundColor: colors.background, + strokeColor: colors.stroke, + borderColor: colors.border, + overlayBorder: false, + hasInset: false, + hasShadow: false + ) + let checkNode = CheckNode(theme: checkNodeTheme, content: .check(isRectangle: true)) + checkNode.isUserInteractionEnabled = false + checkNode.frame = CGRect(origin: .zero, size: item.frame.size) + checkNode.setSelected(checked, animated: false) + self.addSubview(checkNode.view) +``` + +- [ ] **Step 3: Static self-check** + +Run: `grep -n 'import CheckNode\|CheckNode(theme:\|\.checklist(checked' submodules/InstantPageUI/Sources/InstantPageRenderer.swift` +Expected: the import is present and the `.checklist(checked, colors)` case builds a `CheckNode`. + +--- + +## Task 10: Full build to green (controller-driven) + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full Bazel build** + +The controller runs (background, real exit captured — not a subagent): + +```sh +source ~/.zshrc 2>/dev/null; python3 build-system/Make.py --overrideXcodeVersion \ + --cacheDir ~/telegram-bazel-cache build \ + --configurationPath build-system/appstore-configuration.json \ + --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git \ + --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 \ + --configuration=debug_sim_arm64 --continueOnError +``` + +Expected: build succeeds. `--continueOnError` surfaces every remaining `.text`/`.blocks` arity error in one pass. + +- [ ] **Step 2: Fix any compile errors** + +For each error, it will be a missed `.text(...)`/`.blocks(...)` construction (add `, nil`) or destructure (add `, _`), or a flatc-regeneration mismatch (re-check `checkState` casing per the flatbuffers-codegen rules: field `checkState` → accessor `value.checkState`, add method `add(checkState:)`). Fix and re-run Step 1 until green. + +- [ ] **Step 3: Confirm no sentinel survives anywhere** + +Run: `grep -rn 'tg-md-task' submodules/ --include="*.swift" | grep -v '_generated'` +Expected: **no matches** — the sentinel is fully removed. + +--- + +## Task 11: Manual verification + commit + +**Files:** none (verification), then the feature commit + +- [ ] **Step 1: Launch the simulator build and verify the round-trip** + +Install/run the built app (XcodeBuildMCP `build_run_sim` or the produced `.app`). In Saved Messages: + +1. **Send:** type `- [ ] buy milk` and `- [x] done` on separate lines, send. Confirm both render as checkboxes (empty + checked) and that **the checkboxes remain after the send confirms** (not just in the pre-send preview). +2. **Edit:** long-press → Edit. Confirm the editor repopulates `- [ ] buy milk` / `- [x] done`. Change `[ ]` → `[x]`, save; confirm the checkbox updates. +3. **Preview:** confirm the chat-list preview / reply panel shows a checkbox glyph (`☐`/`☑︎`), never raw brackets or a sentinel. +4. **Regression:** send `1. one` / `2. two` and `- a` / `- b`; confirm ordinary numbered and bulleted lists are unaffected. + +- [ ] **Step 2: Commit the feature** + +```bash +git add submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift \ + submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs \ + submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift \ + submodules/BrowserUI/Sources/BrowserMarkdown.swift \ + submodules/BrowserUI/Sources/BrowserReadability.swift \ + submodules/BrowserUI/Sources/InstantPageToMarkdown.swift \ + submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift \ + submodules/InstantPageUI/Sources/InstantPageLayout.swift \ + submodules/InstantPageUI/Sources/InstantPageV2Layout.swift \ + submodules/InstantPageUI/Sources/InstantPageRenderer.swift +git commit -m "$(cat <<'EOF' +InstantPage list checkboxes: first-class checked state + API flags + +Replace the sentinel-in-num task-list prototype with a first-class +`checked: Bool?` on InstantPageListItem (orthogonal to num). Transmit via +the native PageListItem/PageListOrderedItem checkbox/checked flag bits, so +state survives the server for sender and recipients. Render real CheckNode +artwork in the V2 bubble, restore the edit round-trip (reverse markdown), +and fix the preview-text sentinel leak. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review notes (spec coverage) + +- Parsing → Task 3 (markdown forward). Serialization → Task 1 (Postbox + FlatBuffers). Transmission → Task 2 (API flags, all four types). V2 artwork → Tasks 8–9. Edit round-trip → Task 5. Preview leak → Task 6. V1 parity → Task 7. Mechanical sites → Tasks 1,3,4,7,8. Build/verify → Tasks 10–11. +- The `checked`/`num`/`checkedFromTriState`/`triState`/`checkedFromApiFlags`/`apiFlags` symbol names are consistent across Tasks 1–8. +- No sentinel references remain after Tasks 3, 7, 8 (asserted in Task 10 Step 3). diff --git a/docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md b/docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md new file mode 100644 index 0000000000..6ce707a014 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md @@ -0,0 +1,221 @@ +# InstantPage list checkboxes (task-list style) — design + +**Date:** 2026-05-27 +**Status:** Approved for planning + +## Summary + +`InstantPageListItem` gains first-class **task-list checkbox** support — the `- [ ]` / `- [x]` +markdown construct — covering parsing, local serialization (Postbox + FlatBuffers), +API transmission, display, and the edit round-trip in rich-data message bubbles. + +A prior prototype already wired most of this through a **sentinel string stuffed into the +ordered-list `num` field** (`"\u{001f}tg-md-task:checked"` / `:unchecked`). This design +**replaces that sentinel with a first-class `checked: Bool?`** on the list item, orthogonal +to `num`, because: + +1. The Telegram API represents the checkbox as **flags independent of the list number** on + all four list-item types (see "API facts" below). The sentinel-in-`num` cannot represent + an item that is *both* numbered *and* a checkbox, which the API allows. +2. The sentinel never survived the server round-trip — `apiInputBlock` hardcoded `flags: 0` + and dropped it — so checkboxes silently reverted to bullets the moment a message was + confirmed, even for the sender. + +The first-class field fixes transmission (real API flags), makes the local model faithful to +the API, and removes the sentinel hack from three files. + +## API facts (verified against generated `Api.*` source, not inferred) + +All four list-item constructors carry `checkbox` and `checked` as conditional-`true` flags; +`checkbox` = bit 0, `checked` = bit 1. They are **independent** of the ordered-list number. + +``` +pageListItemText#2f58683c flags:# checkbox:flags.0?true checked:flags.1?true text:RichText +pageListItemBlocks#63ca67aa flags:# checkbox:flags.0?true checked:flags.1?true blocks:Vector +pageListOrderedItemText#cd3ea036 flags:# checkbox:flags.0?true checked:flags.1?true num:string text:RichText +pageListOrderedItemBlocks#422931d4 flags:# checkbox:flags.0?true checked:flags.1?true num:string blocks:Vector +``` + +| iOS `Api.` type | Used for | checkbox | checked | other bits | +|---|---|---|---|---| +| `Api.PageListItem` (`.pageListItemText` / `.Blocks`) | send + receive (unordered) | flags.0 | flags.1 | — | +| `Api.PageListOrderedItem` | receive (ordered) | flags.0 | flags.1 | `num` (unconditional string) | +| `Api.InputPageListOrderedItem` | send (ordered) | flags.0 | flags.1 | value=flags.2, type=flags.3 | + +The generated structs expose only `flags: Int32`; checkbox/checked are read/written by +masking bits 0 and 1. The current conversion code ignores them. + +## Data model + +`InstantPageListItem` (`SyncCore_InstantPage.swift`) gains a third associated value: + +```swift +public indirect enum InstantPageListItem: PostboxCoding, Equatable { + case unknown + case text(RichText, String?, Bool?) // (text, num, checked) + case blocks([InstantPageBlock], String?, Bool?) // (blocks, num, checked) +} +``` + +`checked` semantics (orthogonal to `num`): + +- `nil` — not a checkbox item (ordinary bullet / numbered item) +- `false` — checkbox, unchecked +- `true` — checkbox, checked + +A new accessor mirrors the existing `var num`: + +```swift +public extension InstantPageListItem { + var checked: Bool? { … } // returns the third value for .text/.blocks, nil for .unknown +} +``` + +The `var num` accessor is unchanged. + +### Why an associated value (not a new enum case) + +A checkbox item is still a text/blocks item; the only new dimension is the checkbox state. +An associated value keeps every existing `switch` at three cases (one extra bound variable +each) rather than forcing a fourth case into every exhaustive switch. The change is +source-breaking by design — the compiler enumerates every construction/destructure site, so +a full build with `--continueOnError` is the completeness check. + +## Components + +### 1. Enum + local serialization — `SyncCore_InstantPage.swift` + +- Add the `Bool?` associated value to `.text` / `.blocks`; add the `checked` accessor. +- **Postbox**: encode the checked tri-state under a new key (`"ck"`) using the **same + `0=nil, 1=unchecked, 2=checked`** mapping as FlatBuffers (below); encode only when + `checked != nil`, and decode with `decodeInt32ForKey("ck", orElse: 0)` → `0→nil, 1→false, + 2→true`. Old stored data lacks the key → `orElse: 0` → `nil` (backward compatible). +- **FlatBuffers**: add `checkState:int32 (id: 2)` to both `InstantPageListItem_Text` and + `InstantPageListItem_Blocks` in `InstantPageBlock.fbs` (tri-state: `0=nil, 1=unchecked, + 2=checked`). int32 defaults to 0, so absent → `nil` (backward compatible) — this mirrors + the existing `alignment:int32` / `level:int32` pattern and sidesteps optional-scalar-bool + ambiguity. Edit the `.fbs` (source of truth; the Bazel `flatc` genrule regenerates the + Swift); update the hand-written codec in `SyncCore_InstantPage.swift` to read/write + `checkState`. Encode only when non-nil (keeps the wire compact). +- **Equatable**: compare the new value in the `.text` / `.blocks` arms. + +### 2. API transmission — `ApiUtils/InstantPage.swift` + +- **Receive** `init(apiListItem:)` (unordered) and `init(apiListOrderedItem:)` (ordered): + read `flags & (1<<0)` (checkbox present) and `flags & (1<<1)` (checked). Set + `checked = (flags & 1<<0) != 0 ? ((flags & 1<<1) != 0) : nil`. Keep `num` as today + (nil for unordered, the API `num` string for ordered). +- **Send** `apiInputPageListItem()` (→ `Api.PageListItem`) and `apiInputPageOrderedListItem()` + (→ `Api.InputPageListOrderedItem`): when `checked != nil`, set `flags |= (1<<0)` and add + `(1<<1)` when `checked == true`. Preserve the existing `value`/`type` bit handling on the + ordered input. +- The `var num` accessor is unaffected; add `checked` reads where the items are built. +- **Bonus:** incoming Telegra.ph / cross-client task lists now render as checkboxes. + +### 3. Real V2 checkbox artwork — `InstantPageRenderer.swift` + `InstantPageV2Layout.swift` + +The V2 renderer (the live path for rich-message bubbles) currently draws a **placeholder** +square. Replace it with the real artwork: + +- In `InstantPageV2ListMarkerView.rebuildContents()`'s `.checklist` case, host a + `CheckNode(theme:, content: .check(isRectangle: true))` (add its `.view` as a subview), + themed exactly like the V1 `InstantPageChecklistMarkerNode` (`CheckNodeTheme` from + `panelAccentColor` / `pageBackgroundColor` / `controlColor`), and call + `setSelected(checked, animated: false)`. `import CheckNode` (the module is already a + `BUILD` dep of InstantPageUI). +- The marker view's `init` does not receive the theme, only `update(item:theme:)` does. Carry + the three required colors on the `.checklist` marker payload (the layout has + `context.theme`) so the view stays theme-agnostic and the colors are present at init time. +- Layout detection switches from `instantPageTaskListMarkerState(item.num)` to `item.checked`; + the marker kind becomes `.checklist(checked: item.checked == true)` whenever + `item.checked != nil`. Remove the V1/V2 sentinel constants and `instantPageTaskListMarkerState`. + +### 4. Reverse markdown (edit path) — `InstantPageToMarkdown.swift` + +`markdownList` currently ignores the marker, so editing a rich message downgrades checkboxes +to bullets. Read `item.checked` and emit `- [ ] ` / `- [x] ` (task markers are an unordered +construct). Re-classification on save re-parses it back to a checkbox → API flags. + +### 5. preview-text — `InstantPagePreviewText.swift` + +`previewText()` currently prepends `num` blindly, which under the old sentinel leaked +`"\u{001f}tg-md-task:checked. text"` into notifications/reply panels. With the first-class +field, `num` is once again only a real number, so the leak is gone; additionally render a +checkbox glyph (`"☑︎ "` / `"☐ "`) before the text when `checked != nil`. + +### 6. Markdown forward parser — `BrowserMarkdown.swift` + +`markdownListItems` keeps the existing `[ ]`/`[x]`/`[X]` detection +(`markdownTaskListMarker` / `markdownStrippingTaskListMarker` / `markdownApplyTaskListMarker`) +but routes the result into the new `checked` field instead of the `num` sentinel: + +- Unordered task item → `.text(text, nil, state)` (number stays nil). +- Ordered task item (`1. [ ] x`) → `.text(text, "\(ordinal)", state)` — number **and** checkbox + now coexist (previously the sentinel destroyed the number). + +Remove the markdown sentinel constants (`markdownTaskListUncheckedNumber` / +`CheckedNumber`) and `markdownTaskListNumber`. Update the `.blocks(...)` / `.text(...)` +construction arms and the `validate(listItem:)` destructure to the 3-value shape. + +### 7. Other construction/destructure sites (mechanical, compiler-enforced) + +The enum change touches these files' `.text(...)` / `.blocks(...)` sites; all are in +list-handling modules (no external consumer constructs `InstantPageListItem`): + +- `SyncCore_InstantPage.swift` — decodeListItems, Postbox decode/encode, `==`, FlatBuffers codec +- `ApiUtils/InstantPage.swift` — `num`/`checked` accessors, `init(apiListItem:)`, + `init(apiListOrderedItem:)`, `apiInputPageListItem()`, `apiInputPageOrderedListItem()` +- `BrowserReadability.swift` — `.blocks(blocks, nil)` / `.text(...)` builders → add `nil` +- `InstantPageV2Layout.swift` / `InstantPageLayout.swift` — `layoutList` empty-blocks + substitution (`.text(.plain(" "), num)` must carry `checked` through) and the + `.text`/`.blocks` destructures +- `InstantPagePreviewText.swift`, `InstantPageToMarkdown.swift` — destructures (see 4 / 5) + +## Round-trip contract + +``` +compose "- [ ] x" + → markdown parse → .text("x", nil, false) + → render checkbox (V2 CheckNode) + → send: Api.PageListItem.pageListItemText(flags: 1<<0, text:"x") + → server echo → receive: flags bit0 set, bit1 clear → .text("x", nil, false) + → render checkbox on SENDER and RECIPIENT (and native checkbox on other clients) +edit + → reverse markdown reads checked=false → "- [ ] x" + → re-classify → .text("x", nil, false) (identical) +``` + +Postbox/FlatBuffers carry `checked` locally across app restarts; the API flags carry it +across the server. + +## Out of scope + +- Interactivity (tapping a rendered checkbox to toggle it). These are display-only, matching + the rest of InstantPage rendering. +- Inline images/videos inside list items (unchanged; pre-existing behavior). + +## Testing / verification + +No unit tests exist in this project. Verify manually after a full build: + +1. **Build** the full `Telegram/Telegram` target (`--continueOnError`) — the compile-breaking + enum change surfaces any missed `.text`/`.blocks` site. +2. **Send round-trip:** send a rich message `- [ ] a` / `- [x] b` to Saved Messages; confirm + the checkboxes persist **after the send confirms** (not just in the pre-send preview), and + that checked/unchecked states are correct. +3. **Edit round-trip:** edit that message; confirm the editor repopulates `- [ ] a` / `- [x] b` + and saving preserves the states. +4. **Preview surfaces:** confirm the chat list / notification / reply panel previews show a + checkbox glyph, never the raw sentinel or `num`. +5. **Regression:** ordinary ordered (`1.`) and unordered (`-`) lists still render with correct + numbers/bullets. + +## Risks + +- **FlatBuffers regen:** the checked-in `*_generated.swift` is stale; the build regenerates + from the `.fbs`. Follow the flatc casing rules already documented (edit `.fbs`, not the + generated Swift). +- **Source-breaking enum change:** mitigated by the compiler — every site must be updated to + build. The full-build step is the completeness gate. +- **Tri-state encoding:** both Postbox (`"ck"` int) and FlatBuffers (`checkState:int32`) treat + absent/0 as `nil`, so pre-existing stored pages decode unchanged. diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index 996b15b381..ed1ef99ea2 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -629,7 +629,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg collect(blocks: blocks) case let .list(items, _): for item in items { - if case let .blocks(blocks, _) = item { + if case let .blocks(blocks, _, _) = item { collect(blocks: blocks) } } diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index fb343116a1..5a1481b9bc 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -20,8 +20,6 @@ private let markdownInlineHTMLInlineIntent = InlinePresentationIntent(rawValue: private let markdownDefaultBlockImageDimensions = PixelDimensions(width: 1200, height: 900) private let markdownDefaultInlineImageDimensions = PixelDimensions(width: 18, height: 18) private let markdownImageParsingEnabled = false -private let markdownTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" -private let markdownTaskListCheckedNumber = "\u{001f}tg-md-task:checked" private let markdownRawHTMLTagRegex = try! NSRegularExpression(pattern: #"]*?>"#) private let markdownFormulaPlaceholderRegex = try! NSRegularExpression(pattern: #"TGMDMATH\d+TGMD"#) private let markdownVoidHTMLTags: Set = [ @@ -200,7 +198,7 @@ private final class MarkdownConversionBudget { guard self.registerBlockDepth(depth) else { return false } - if case let .blocks(blocks, _) = listItem { + if case let .blocks(blocks, _, _) = listItem { return self.validate(blocks: blocks, depth: depth + 1, count: &count) } else { return true @@ -1447,42 +1445,35 @@ private func markdownListItems(from nodes: [MarkdownIntentNode], ordered: Bool, return nil } let taskListState = markdownApplyTaskListMarker(to: &blocks) - let number: String? - if let taskListState { - number = markdownTaskListNumber(for: taskListState) - } else if ordered { - number = "\(ordinal)" - } else { - number = nil + let checked: Bool? + switch taskListState { + case .unchecked: + checked = false + case .checked: + checked = true + case nil: + checked = nil } + let number: String? = ordered ? "\(ordinal)" : nil if blocks.isEmpty { - if let number { - result.append(.text(.plain(" "), number)) + if checked != nil || number != nil { + result.append(.text(.plain(" "), number, checked)) } continue } if blocks.count == 1, case let .paragraph(text) = blocks[0] { - if number != nil && markdownIsWhitespaceOnly(text) { - result.append(.text(.plain(" "), number)) + if markdownIsWhitespaceOnly(text) && (checked != nil || number != nil) { + result.append(.text(.plain(" "), number, checked)) } else { - result.append(.text(text, number)) + result.append(.text(text, number, checked)) } } else { - result.append(.blocks(blocks, number)) + result.append(.blocks(blocks, number, checked)) } } return result } -private func markdownTaskListNumber(for state: MarkdownTaskListState) -> String { - switch state { - case .unchecked: - return markdownTaskListUncheckedNumber - case .checked: - return markdownTaskListCheckedNumber - } -} - private func markdownApplyTaskListMarker(to blocks: inout [InstantPageBlock]) -> MarkdownTaskListState? { guard !blocks.isEmpty, case let .paragraph(text) = blocks[0] else { return nil @@ -2285,11 +2276,11 @@ private func markdownFirstParagraphText(from blocks: [InstantPageBlock], depth: case let .list(items, _): for item in items { switch item { - case let .text(text, _): + case let .text(text, _, _): if !text.plainText.isEmpty { return text.plainText } - case let .blocks(blocks, _): + case let .blocks(blocks, _, _): if let text = markdownFirstParagraphText(from: blocks, depth: depth + 1) { return text } diff --git a/submodules/BrowserUI/Sources/BrowserReadability.swift b/submodules/BrowserUI/Sources/BrowserReadability.swift index be9bfa240e..0d56552f2d 100644 --- a/submodules/BrowserUI/Sources/BrowserReadability.swift +++ b/submodules/BrowserUI/Sources/BrowserReadability.swift @@ -610,17 +610,17 @@ private func parseList(_ input: [String: Any], _ url: String, _ media: inout [En if parseAsBlocks { let blocks = parsePageBlocks(subcontent, url, &media) if !blocks.isEmpty { - items.append(.blocks(blocks, nil)) + items.append(.blocks(blocks, nil, nil)) } } else { - items.append(.text(trim(parseRichText(item, &media)), nil)) + items.append(.text(trim(parseRichText(item, &media)), nil, nil)) } } } let ordered = tag == "ol" var allEmpty = true for item in items { - if case let .text(text, _) = item { + if case let .text(text, _, _) = item { if case .empty = text { } else { let plainText = text.plainText diff --git a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift index 4e3762c32c..5db9133393 100644 --- a/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift +++ b/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift @@ -166,14 +166,24 @@ private func markdownList(items: [InstantPageListItem], ordered: Bool, indent: I var lines: [String] = [] var index = 1 for item in items { - // The stored per-item marker string (`InstantPageListItem`'s `String?`) is - // intentionally ignored: ordered markers are regenerated from the running - // index (CommonMark renumbers anyway) and the unordered marker is fixed. - let marker = ordered ? "\(index). " : "- " + // Ordered markers are regenerated from the running index (CommonMark renumbers + // anyway); the unordered marker is fixed. A task-list `checked` state is emitted + // as a GitHub task marker so re-classification on save re-parses it as a checkbox. + let listMarker = ordered ? "\(index). " : "- " + let taskMarker: String + switch item.checked { + case .some(false): + taskMarker = "[ ] " + case .some(true): + taskMarker = "[x] " + case .none: + taskMarker = "" + } + let marker = "\(listMarker)\(taskMarker)" switch item { - case let .text(text, _): + case let .text(text, _, _): lines.append("\(indentString)\(marker)\(markdownInline(from: text))") - case let .blocks(blocks, _): + case let .blocks(blocks, _, _): var remainder = blocks var markerLineText = "" if case let .paragraph(text)? = remainder.first { diff --git a/submodules/InstantPageUI/Sources/InstantPageLayout.swift b/submodules/InstantPageUI/Sources/InstantPageLayout.swift index 090dadfd43..1e4ac8765b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageLayout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageLayout.swift @@ -131,21 +131,8 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str return attributedString } -private let instantPageTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" -private let instantPageTaskListCheckedNumber = "\u{001f}tg-md-task:checked" private let instantPageChecklistMarkerSize = CGSize(width: 18.0, height: 18.0) -private func instantPageTaskListMarkerState(_ number: String?) -> Bool? { - switch number { - case instantPageTaskListUncheckedNumber: - return false - case instantPageTaskListCheckedNumber: - return true - default: - return nil - } -} - private func instantPageFirstTextLineMidY(in items: [InstantPageItem]) -> CGFloat? { for item in items { if let textItem = item as? InstantPageTextItem { @@ -372,7 +359,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: var hasNums = false if ordered { for item in contentItems { - if instantPageTaskListMarkerState(item.num) != nil { + if item.checked != nil { hasTaskMarkers = true } else if let num = item.num, !num.isEmpty { hasNums = true @@ -380,7 +367,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: } } else { for item in contentItems { - if instantPageTaskListMarkerState(item.num) != nil { + if item.checked != nil { hasTaskMarkers = true break } @@ -389,7 +376,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: for i in 0 ..< contentItems.count { let item = contentItems[i] - if let checked = instantPageTaskListMarkerState(item.num) { + if let checked = item.checked { let checklistItem = InstantPageChecklistMarkerItem(frame: CGRect(origin: .zero, size: instantPageChecklistMarkerSize), checked: checked) if ordered { maxIndexWidth = max(maxIndexWidth, instantPageChecklistMarkerSize.width) @@ -432,11 +419,11 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: setupStyleStack(styleStack, theme: theme, category: .paragraph, link: false) var effectiveItem = item - if case let .blocks(blocks, num) = effectiveItem, blocks.isEmpty { - effectiveItem = .text(.plain(" "), num) + if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num, checked) } switch effectiveItem { - case let .text(text, _): + case let .text(text, _, _): let (textItem, textItems, textItemSize) = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, offset: CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height), media: media, webpage: webpage, fitToWidth: fitToWidth) contentSize.height += textItemSize.height @@ -466,7 +453,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation: indexItems[i].frame = itemFrame listItems.append(indexItems[i]) listItems.append(contentsOf: textItems) - case let .blocks(blocks, _): + case let .blocks(blocks, _, _): var previousBlock: InstantPageBlock? var originY: CGFloat = contentSize.height var firstBlockLineMidY: CGFloat? diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 19de7e9208..cc8507ea8c 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import Display +import CheckNode import SwiftSignalKit import TelegramCore import TelegramPresentationData @@ -1359,22 +1360,21 @@ final class InstantPageV2ListMarkerView: UIView, InstantPageItemView { label.textAlignment = .right label.frame = CGRect(origin: .zero, size: item.frame.size) self.addSubview(label) - case let .checklist(checked): - // V0 placeholder: simple square outline (unchecked) or filled square (checked). - // The existing V1 InstantPageChecklistMarkerItem artwork can be ported later if needed. - let outer = CALayer() - outer.borderColor = item.color.cgColor - outer.borderWidth = 1.0 - outer.cornerRadius = 3.0 - outer.frame = CGRect(origin: .zero, size: item.frame.size) - self.layer.addSublayer(outer) - if checked { - let fill = CALayer() - fill.backgroundColor = item.color.cgColor - fill.cornerRadius = 3.0 - fill.frame = CGRect(origin: .zero, size: item.frame.size).insetBy(dx: 2.0, dy: 2.0) - self.layer.addSublayer(fill) - } + case let .checklist(checked, colors): + let checkNodeTheme = CheckNodeTheme( + backgroundColor: colors.background, + strokeColor: colors.stroke, + borderColor: colors.border, + overlayBorder: false, + hasInset: false, + hasShadow: false + ) + let checkNode = CheckNode(theme: checkNodeTheme, content: .check(isRectangle: true)) + checkNode.displaysAsynchronously = false + checkNode.isUserInteractionEnabled = false + checkNode.frame = CGRect(origin: .zero, size: item.frame.size) + checkNode.setSelected(checked, animated: false) + self.addSubview(checkNode.view) } } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 54ebfb71fe..94b02ffee0 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -136,10 +136,22 @@ public struct InstantPageV2DividerItem { public let color: UIColor } +public struct InstantPageV2CheckboxColors { + public let background: UIColor + public let stroke: UIColor + public let border: UIColor + + public init(background: UIColor, stroke: UIColor, border: UIColor) { + self.background = background + self.stroke = stroke + self.border = border + } +} + public enum InstantPageV2ListMarkerKind { case bullet case number(String) - case checklist(checked: Bool) + case checklist(checked: Bool, colors: InstantPageV2CheckboxColors) } public struct InstantPageV2ListMarkerItem { @@ -1994,22 +2006,6 @@ private func layoutBlockQuote( return result } -// MARK: - Task-list marker helpers (copied verbatim from V1 InstantPageLayout.swift:134–146) - -private let instantPageTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked" -private let instantPageTaskListCheckedNumber = "\u{001f}tg-md-task:checked" - -private func instantPageTaskListMarkerState(_ number: String?) -> Bool? { - switch number { - case instantPageTaskListUncheckedNumber: - return false - case instantPageTaskListCheckedNumber: - return true - default: - return nil - } -} - // MARK: - List layout (ported from V1 InstantPageLayout.swift lines 365–516) private func layoutList( @@ -2026,7 +2022,7 @@ private func layoutList( if ordered { for item in listItems { - if instantPageTaskListMarkerState(item.num) != nil { + if item.checked != nil { hasTaskMarkers = true } else if let num = item.num, !num.isEmpty { hasNums = true @@ -2034,7 +2030,7 @@ private func layoutList( } } else { for item in listItems { - if instantPageTaskListMarkerState(item.num) != nil { + if item.checked != nil { hasTaskMarkers = true break } @@ -2048,13 +2044,18 @@ private func layoutList( } let checklistMarkerSize = CGSize(width: 18.0, height: 18.0) + let checkboxColors = InstantPageV2CheckboxColors( + background: context.theme.panelAccentColor, + stroke: context.theme.pageBackgroundColor, + border: context.theme.controlColor + ) var markerInfos: [MarkerInfo] = [] for (i, item) in listItems.enumerated() { - if let checked = instantPageTaskListMarkerState(item.num) { + if let checked = item.checked { if ordered { maxIndexWidth = max(maxIndexWidth, checklistMarkerSize.width) } - markerInfos.append(MarkerInfo(kind: .checklist(checked: checked), naturalWidth: checklistMarkerSize.width)) + markerInfos.append(MarkerInfo(kind: .checklist(checked: checked, colors: checkboxColors), naturalWidth: checklistMarkerSize.width)) } else if ordered { let value: String if hasNums { @@ -2109,12 +2110,12 @@ private func layoutList( // Effective item: if a .blocks item is empty, treat as a single space. var effectiveItem = item - if case let .blocks(blocks, num) = effectiveItem, blocks.isEmpty { - effectiveItem = .text(.plain(" "), num) + if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { + effectiveItem = .text(.plain(" "), num, checked) } switch effectiveItem { - case let .text(text, _): + case let .text(text, _, _): // Layout text content. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) @@ -2163,7 +2164,7 @@ private func layoutList( result.append(contentsOf: textLaidOutItems) contentHeight += textSize.height - case let .blocks(blocks, _): + case let .blocks(blocks, _, _): // Nested block content (e.g. sub-list, paragraphs). var previousBlock: InstantPageBlock? let originY = contentHeight diff --git a/submodules/SettingsUI/Sources/CachedFaqInstantPage.swift b/submodules/SettingsUI/Sources/CachedFaqInstantPage.swift index 91024939fa..3cc1dec724 100644 --- a/submodules/SettingsUI/Sources/CachedFaqInstantPage.swift +++ b/submodules/SettingsUI/Sources/CachedFaqInstantPage.swift @@ -38,7 +38,7 @@ func faqSearchableItems(context: AccountContext, resolvedUrl: Signal Bool? { + guard (flags & (1 << 0)) != 0 else { + return nil + } + return (flags & (1 << 1)) != 0 + } + + static func apiFlags(fromChecked checked: Bool?) -> Int32 { + guard let checked else { + return 0 + } + var flags: Int32 = 1 << 0 + if checked { + flags |= (1 << 1) + } + return flags + } } extension InstantPageListItem { @@ -31,10 +60,10 @@ extension InstantPageListItem { switch apiListItem { case let .pageListItemText(pageListItemTextData): let text = pageListItemTextData.text - self = .text(RichText(apiText: text), nil) + self = .text(RichText(apiText: text), nil, InstantPageListItem.checkedFromApiFlags(pageListItemTextData.flags)) case let .pageListItemBlocks(pageListItemBlocksData): let blocks = pageListItemBlocksData.blocks - self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), nil) + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), nil, InstantPageListItem.checkedFromApiFlags(pageListItemBlocksData.flags)) } } @@ -42,19 +71,19 @@ extension InstantPageListItem { switch apiListOrderedItem { case let .pageListOrderedItemText(pageListOrderedItemTextData): let (num, text) = (pageListOrderedItemTextData.num, pageListOrderedItemTextData.text) - self = .text(RichText(apiText: text), num) + self = .text(RichText(apiText: text), num, InstantPageListItem.checkedFromApiFlags(pageListOrderedItemTextData.flags)) case let .pageListOrderedItemBlocks(pageListOrderedItemBlocksData): let (num, blocks) = (pageListOrderedItemBlocksData.num, pageListOrderedItemBlocksData.blocks) - self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), num) + self = .blocks(blocks.map({ InstantPageBlock(apiBlock: $0) }), num, InstantPageListItem.checkedFromApiFlags(pageListOrderedItemBlocksData.flags)) } } func apiInputPageListItem() -> Api.PageListItem { switch self { - case let .text(value, _): - return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: 0, text: value.apiRichText())) - case let .blocks(blocks, _): - return .pageListItemBlocks(Api.PageListItem.Cons_pageListItemBlocks(flags: 0, blocks: blocks.compactMap { $0.apiInputBlock() })) + case let .text(value, _, checked): + return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: InstantPageListItem.apiFlags(fromChecked: checked), text: value.apiRichText())) + case let .blocks(blocks, _, checked): + return .pageListItemBlocks(Api.PageListItem.Cons_pageListItemBlocks(flags: InstantPageListItem.apiFlags(fromChecked: checked), blocks: blocks.compactMap { $0.apiInputBlock() })) case .unknown: return .pageListItemText(Api.PageListItem.Cons_pageListItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")))) } @@ -62,25 +91,25 @@ extension InstantPageListItem { func apiInputPageOrderedListItem() -> Api.InputPageListOrderedItem { switch self { - case let .text(value, num): - var flags: Int32 = 0 - + case let .text(value, num, checked): + var flags: Int32 = InstantPageListItem.apiFlags(fromChecked: checked) + var inputNum: Int32? if let num, let numValue = Int32(num) { inputNum = numValue flags |= (1 << 2) } - + return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: flags, text: value.apiRichText(), value: inputNum, type: nil)) - case let .blocks(blocks, num): - var flags: Int32 = 0 - + case let .blocks(blocks, num, checked): + var flags: Int32 = InstantPageListItem.apiFlags(fromChecked: checked) + var inputNum: Int32? if let num, let numValue = Int32(num) { inputNum = numValue flags |= (1 << 2) } - + return .inputPageListOrderedItemBlocks(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemBlocks(flags: flags, blocks: blocks.compactMap { $0.apiInputBlock() }, value: inputNum, type: nil)) case .unknown: return .inputPageListOrderedItemText(Api.InputPageListOrderedItem.Cons_inputPageListOrderedItemText(flags: 0, text: .textPlain(Api.RichText.Cons_textPlain(text: "")), value: nil, type: nil)) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift index 0e0574d48b..d1694a05f6 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift @@ -41,7 +41,7 @@ private func decodeListItems(_ decoder: PostboxDecoder) -> [InstantPageListItem] if !legacyItems.isEmpty { var items: [InstantPageListItem] = [] for item in legacyItems { - items.append(.text(item, nil)) + items.append(.text(item, nil, nil)) } return items } @@ -1021,15 +1021,15 @@ private enum InstantPageListItemType: Int32 { public indirect enum InstantPageListItem: PostboxCoding, Equatable { case unknown - case text(RichText, String?) - case blocks([InstantPageBlock], String?) + case text(RichText, String?, Bool?) + case blocks([InstantPageBlock], String?, Bool?) public init(decoder: PostboxDecoder) { switch decoder.decodeInt32ForKey("r", orElse: 0) { case InstantPageListItemType.text.rawValue: - self = .text(decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, decoder.decodeOptionalStringForKey("n")) + self = .text(decoder.decodeObjectForKey("t", decoder: { RichText(decoder: $0) }) as! RichText, decoder.decodeOptionalStringForKey("n"), InstantPageListItem.checkedFromTriState(decoder.decodeInt32ForKey("ck", orElse: 0))) case InstantPageListItemType.blocks.rawValue: - self = .blocks(decoder.decodeObjectArrayWithDecoderForKey("b"), decoder.decodeOptionalStringForKey("n")) + self = .blocks(decoder.decodeObjectArrayWithDecoderForKey("b"), decoder.decodeOptionalStringForKey("n"), InstantPageListItem.checkedFromTriState(decoder.decodeInt32ForKey("ck", orElse: 0))) default: self = .unknown } @@ -1037,7 +1037,7 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { public func encode(_ encoder: PostboxEncoder) { switch self { - case let .text(text, num): + case let .text(text, num, checked): encoder.encodeInt32(InstantPageListItemType.text.rawValue, forKey: "r") encoder.encodeObject(text, forKey: "t") if let num = num { @@ -1045,7 +1045,12 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "n") } - case let .blocks(blocks, num): + if let triState = InstantPageListItem.triState(fromChecked: checked) { + encoder.encodeInt32(triState, forKey: "ck") + } else { + encoder.encodeNil(forKey: "ck") + } + case let .blocks(blocks, num, checked): encoder.encodeInt32(InstantPageListItemType.blocks.rawValue, forKey: "r") encoder.encodeObjectArray(blocks, forKey: "b") if let num = num { @@ -1053,11 +1058,33 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "n") } + if let triState = InstantPageListItem.triState(fromChecked: checked) { + encoder.encodeInt32(triState, forKey: "ck") + } else { + encoder.encodeNil(forKey: "ck") + } default: break } } + static func checkedFromTriState(_ value: Int32) -> Bool? { + switch value { + case 1: return false + case 2: return true + default: return nil + } + } + + /// Returns the persisted tri-state (1 = unchecked, 2 = checked) or nil when not a checkbox item. + static func triState(fromChecked checked: Bool?) -> Int32? { + switch checked { + case .some(false): return 1 + case .some(true): return 2 + case .none: return nil + } + } + public static func ==(lhs: InstantPageListItem, rhs: InstantPageListItem) -> Bool { switch lhs { case .unknown: @@ -1066,14 +1093,14 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { } else { return false } - case let .text(lhsText, lhsNum): - if case let .text(rhsText, rhsNum) = rhs, lhsText == rhsText, lhsNum == rhsNum { + case let .text(lhsText, lhsNum, lhsChecked): + if case let .text(rhsText, rhsNum, rhsChecked) = rhs, lhsText == rhsText, lhsNum == rhsNum, lhsChecked == rhsChecked { return true } else { return false } - case let .blocks(lhsBlocks, lhsNum): - if case let .blocks(rhsBlocks, rhsNum) = rhs, lhsBlocks == rhsBlocks, lhsNum == rhsNum { + case let .blocks(lhsBlocks, lhsNum, lhsChecked): + if case let .blocks(rhsBlocks, rhsNum, rhsChecked) = rhs, lhsBlocks == rhsBlocks, lhsNum == rhsNum, lhsChecked == rhsChecked { return true } else { return false @@ -1087,7 +1114,7 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { guard let textValue = flatBuffersObject.value(type: TelegramCore_InstantPageListItem_Text.self) else { throw FlatBuffersError.missingRequiredField() } - self = .text(try RichText(flatBuffersObject: textValue.text), textValue.number) + self = .text(try RichText(flatBuffersObject: textValue.text), textValue.number, InstantPageListItem.checkedFromTriState(textValue.checkState)) case .instantpagelistitemBlocks: guard let blocksValue = flatBuffersObject.value(type: TelegramCore_InstantPageListItem_Blocks.self) else { @@ -1096,7 +1123,7 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { let blocks = try (0 ..< blocksValue.blocksCount).map { i in return try InstantPageBlock(flatBuffersObject: blocksValue.blocks(at: i)!) } - self = .blocks(blocks, blocksValue.number) + self = .blocks(blocks, blocksValue.number, InstantPageListItem.checkedFromTriState(blocksValue.checkState)) case .instantpagelistitemUnknown: self = .unknown case .none_: @@ -1109,28 +1136,34 @@ public indirect enum InstantPageListItem: PostboxCoding, Equatable { let offset: Offset switch self { - case let .text(text, number): + case let .text(text, number, checked): valueType = .instantpagelistitemText let textOffset = text.encodeToFlatBuffers(builder: &builder) let numberOffset = number.map { builder.create(string: $0) } ?? Offset() - + let start = TelegramCore_InstantPageListItem_Text.startInstantPageListItem_Text(&builder) TelegramCore_InstantPageListItem_Text.add(text: textOffset, &builder) if let _ = number { TelegramCore_InstantPageListItem_Text.add(number: numberOffset, &builder) } + if let triState = InstantPageListItem.triState(fromChecked: checked) { + TelegramCore_InstantPageListItem_Text.add(checkState: triState, &builder) + } offset = TelegramCore_InstantPageListItem_Text.endInstantPageListItem_Text(&builder, start: start) - case let .blocks(blocks, number): + case let .blocks(blocks, number, checked): valueType = .instantpagelistitemBlocks let blocksOffsets = blocks.map { $0.encodeToFlatBuffers(builder: &builder) } let blocksOffset = builder.createVector(ofOffsets: blocksOffsets, len: blocksOffsets.count) let numberOffset = number.map { builder.create(string: $0) } ?? Offset() - + let start = TelegramCore_InstantPageListItem_Blocks.startInstantPageListItem_Blocks(&builder) TelegramCore_InstantPageListItem_Blocks.addVectorOf(blocks: blocksOffset, &builder) if let _ = number { TelegramCore_InstantPageListItem_Blocks.add(number: numberOffset, &builder) } + if let triState = InstantPageListItem.triState(fromChecked: checked) { + TelegramCore_InstantPageListItem_Blocks.add(checkState: triState, &builder) + } offset = TelegramCore_InstantPageListItem_Blocks.endInstantPageListItem_Blocks(&builder, start: start) case .unknown: valueType = .instantpagelistitemUnknown diff --git a/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift index 7dfaf15708..04e5a2960a 100644 --- a/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift +++ b/submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift @@ -57,13 +57,16 @@ extension InstantPageListItem { switch self { case .unknown: return "" - case let .text(text, num): - if let num, !num.isEmpty { - return "\(num). \(text.previewText())" + case let .text(text, num, checked): + let body = text.previewText() + if let checked { + return "\(checked ? "☑︎" : "☐") \(body)" + } else if let num, !num.isEmpty { + return "\(num). \(body)" } else { - return text.previewText() + return body } - case let .blocks(blocks, num): + case let .blocks(blocks, num, checked): var blocksText = "" for block in blocks { if !blocksText.isEmpty { @@ -71,7 +74,9 @@ extension InstantPageListItem { } blocksText.append(block.previewText()) } - if let num { + if let checked { + return "\(checked ? "☑︎" : "☐") \(blocksText)" + } else if let num { return "\(num). \(blocksText)" } else { return blocksText