mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Render RichText.textDate (API constructor textDate(flags, text, date)) as a
locale/timezone-correct, relative-aware, tappable date inside V2 rich-message
bubbles, reusing the existing stringForEntityFormattedDate autoformatter.
- Model: add RichText.textDate(text📅format:) with Postbox coding,
FlatBuffers schema (RichText.fbs; flatc regenerates), Equatable and plainText;
parse flags->DateTimeFormat? at the API boundary (flags==0 => nil, matching
the messageEntityFormattedDate convention) with a symmetric apiRichText()
round-trip. The new case is handled in every exhaustive RichText switch
(SyncCore, ApiUtils, InstantPageTextItem, InstantPageAnchorPath,
InstantPagePreviewText, BrowserReadability, BrowserMarkdown).
- Render: attributedStringForRichText gains an optional formatDate closure;
the V2 layout builds it from the message's strings/dateTimeFormat and threads
it to every text-building call site, formatting the timestamp and tagging the
run with TelegramTextAttributes.Date.
- Tappable: the Date attribute is added to linkSelectionRects hit-testing and
mapped in the rich bubble's entityTapContent to the existing .date tap action
(opens the date context menu).
- Live update: the V2 layout accumulates a page-wide formattedDateUpdatePeriod
(>=10s) for relative dates; the rich bubble schedules a refresh timer on it,
recreating it only when the period changes. The currentPageLayout cache is
bypassed for relative-date pages so the timer-driven relayout actually
re-formats the date.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
7.8 KiB
Swift
155 lines
7.8 KiB
Swift
import Foundation
|
|
import TelegramCore
|
|
|
|
/// Recurses the `InstantPage` block tree to locate the anchor `name`, returning the
|
|
/// details-sibling-ordinal path of enclosing `<details>` blocks (outermost first).
|
|
///
|
|
/// - `nil` — the anchor exists nowhere.
|
|
/// - `[]` — the anchor exists outside any `<details>` (top level, a table cell, a quote, etc.).
|
|
/// - `[2,0]`— inside the 3rd top-level `<details>`, then that details' 1st nested `<details>`.
|
|
///
|
|
/// Ordinals (not layout indices) because the layout's details index counter is
|
|
/// expansion-dependent. Consumers map ordinals to live indices via
|
|
/// `InstantPageV2View.firstCollapsedDetails(forOrdinalPath:)`.
|
|
public func instantPageAnchorPath(in instantPage: InstantPage, name: String) -> [Int]? {
|
|
var ordinal = 0
|
|
return instantPageAnchorPathSearch(instantPage.blocks, name: name, detailsOrdinal: &ordinal)
|
|
}
|
|
|
|
/// Searches `blocks` at a single details-nesting level. `detailsOrdinal` is the running count of
|
|
/// `<details>` blocks already passed at this level; container blocks that flatten in the layout
|
|
/// (`.blockQuote`/`.list`/`.cover`/`.postEmbed`) recurse sharing this counter, while a `<details>`
|
|
/// recurses with a fresh counter (a new level).
|
|
private func instantPageAnchorPathSearch(
|
|
_ blocks: [InstantPageBlock],
|
|
name: String,
|
|
detailsOrdinal: inout Int
|
|
) -> [Int]? {
|
|
for block in blocks {
|
|
switch block {
|
|
case let .anchor(anchorName):
|
|
if anchorName == name { return [] }
|
|
case let .title(text), let .subtitle(text), let .header(text), let .subheader(text),
|
|
let .paragraph(text), let .footer(text), let .kicker(text), let .thinking(text):
|
|
if richTextContainsAnchor(text, name: name) { return [] }
|
|
case let .heading(text, _):
|
|
if richTextContainsAnchor(text, name: name) { return [] }
|
|
case let .authorDate(author, _):
|
|
if richTextContainsAnchor(author, name: name) { return [] }
|
|
case let .preformatted(text, _):
|
|
if richTextContainsAnchor(text, name: name) { return [] }
|
|
case let .pullQuote(text, caption):
|
|
if richTextContainsAnchor(text, name: name) || richTextContainsAnchor(caption, name: name) { return [] }
|
|
case let .details(title, childBlocks, _):
|
|
if richTextContainsAnchor(title, name: name) { return [] } // title is always laid out
|
|
var childOrdinal = 0
|
|
if let sub = instantPageAnchorPathSearch(childBlocks, name: name, detailsOrdinal: &childOrdinal) {
|
|
return [detailsOrdinal] + sub
|
|
}
|
|
detailsOrdinal += 1
|
|
case let .blockQuote(quoteBlocks, caption):
|
|
if richTextContainsAnchor(caption, name: name) { return [] }
|
|
if let r = instantPageAnchorPathSearch(quoteBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
|
case let .list(items, _):
|
|
for listItem in items {
|
|
switch listItem {
|
|
case let .text(text, _, _):
|
|
if richTextContainsAnchor(text, name: name) { return [] }
|
|
case let .blocks(itemBlocks, _, _):
|
|
if let r = instantPageAnchorPathSearch(itemBlocks, name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
|
case .unknown:
|
|
break
|
|
}
|
|
}
|
|
case let .cover(inner):
|
|
if let r = instantPageAnchorPathSearch([inner], name: name, detailsOrdinal: &detailsOrdinal) { return r }
|
|
case let .table(title, rows, _, _):
|
|
if richTextContainsAnchor(title, name: name) { return [] }
|
|
for row in rows {
|
|
for cell in row.cells {
|
|
if let cellText = cell.text, richTextContainsAnchor(cellText, name: name) { return [] }
|
|
}
|
|
}
|
|
default:
|
|
// .unsupported/.divider/.formula/.image/.video/.audio/.webEmbed/.channelBanner/.map —
|
|
// leaf/media blocks with no anchor-bearing text. (.relatedArticles also lands here: the
|
|
// V2 layout discards its title and lays out only the article media, so its title text is
|
|
// never rendered.)
|
|
//
|
|
// CRITICAL — the recursion set here must match the containers the V2 layout recurses
|
|
// through layoutBlock (and thus counts <details> in via detailsIndexCounter). Those are
|
|
// exactly .blockQuote, .cover, and .list's .blocks items — all handled above, sharing
|
|
// detailsOrdinal. The following carry [InstantPageBlock] children but are deliberately
|
|
// NOT recursed because the V2 layout does NOT lay their children out as blocks, so it
|
|
// never counts a nested <details> in them — recursing here while sharing detailsOrdinal
|
|
// would desync our ordinals from the layout:
|
|
// • .collage/.slideshow — layoutCollage/layoutSlideshow lay out only .image/.video children.
|
|
// • .postEmbed — layoutMediaWithCaption lays out only its caption (a real .text item,
|
|
// so a caption anchor is found by anchorFrame directly); its `blocks` are ignored.
|
|
// Any anchor inside a non-laid-out child is unresolvable by anchorFrame anyway, so
|
|
// skipping it here is a no-op either way.
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// True if the `RichText` tree contains an inline `.anchor` whose name equals `name`.
|
|
private func richTextContainsAnchor(_ text: RichText, name: String) -> Bool {
|
|
switch text {
|
|
case .empty, .plain, .image, .formula, .textCustomEmoji:
|
|
return false
|
|
case let .anchor(inner, anchorName):
|
|
if anchorName == name { return true }
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .concat(parts):
|
|
for part in parts {
|
|
if richTextContainsAnchor(part, name: name) { return true }
|
|
}
|
|
return false
|
|
case let .bold(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .italic(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .underline(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .strikethrough(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .fixed(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .marked(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .`subscript`(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .superscript(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textAutoEmail(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textAutoPhone(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textAutoUrl(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textBankCard(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textBotCommand(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textCashtag(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textHashtag(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textMention(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textSpoiler(inner):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .url(inner, _, _):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .email(inner, _):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .phone(inner, _):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textMentionName(inner, _):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
case let .textDate(inner, _, _):
|
|
return richTextContainsAnchor(inner, name: name)
|
|
}
|
|
}
|