Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Mikhail Filimonov 2026-05-27 16:11:06 +01:00
commit 4cb80e456b
14 changed files with 1736 additions and 140 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<PageBlock>
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<PageBlock>
```
| 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.

View file

@ -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)
}
}

View file

@ -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: #"</?([A-Za-z][A-Za-z0-9:-]*)\b[^>]*?>"#)
private let markdownFormulaPlaceholderRegex = try! NSRegularExpression(pattern: #"TGMDMATH\d+TGMD"#)
private let markdownVoidHTMLTags: Set<String> = [
@ -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
}

View file

@ -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

View file

@ -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 {

View file

@ -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?

View file

@ -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)
}
}
}

View file

@ -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:134146)
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 365516)
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

View file

@ -38,7 +38,7 @@ func faqSearchableItems(context: AccountContext, resolvedUrl: Signal<ResolvedUrl
case let .list(items, false):
if let currentSection = currentSection {
for item in items {
if case let .text(itemText, _) = item, case let .url(text, url, _) = itemText {
if case let .text(itemText, _, _) = item, case let .url(text, url, _) = itemText {
let (_, anchor) = extractAnchor(string: url)
guard let anchor else {
continue

View file

@ -214,11 +214,13 @@ table InstantPageListItem {
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);
}
table InstantPageListItem_Unknown {}

View file

@ -16,14 +16,43 @@ extension InstantPageCaption {
public extension InstantPageListItem {
var num: String? {
switch self {
case let .text(_, num):
case let .text(_, num, _):
return num
case let .blocks(_, 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
}
}
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
}
}
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))

View file

@ -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

View file

@ -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