Rich messages: "Show more" to load the full page for partial InstantPages

Server-sent rich messages can arrive partial (RichMessage isPartial ->
instantPage.isComplete == false). The bubble renders the partial page with
an inline "Show more" link; tapping it fetches the full page once and
expands in place.

- RichTextMessageAttribute keeps the partial instantPage and gains an
  optional fullInstantPage, filled by engine.messages.requestFullRichText
  via transaction.updateMessage. The seed-config merge preserves a fetched
  fullInstantPage across later server updates.
- ChatMessageRichDataBubbleContentNode: node-local, per-message expand
  state (collapsed on every fresh display, even when fullInstantPage is
  already cached); renders (expanded ? fullInstantPage : nil) ?? instantPage;
  gates the link on !expanded && !isComplete (+ not streaming, Cloud-only,
  not preview/messageOptions); expand state threaded through both layout
  caches; shimmer while fetching (instant when cached); bubble grows
  downward on expand via setInvertOffsetDirection.
- New localized string Chat.RichText.ShowMore; docs in
  docs/instantpage-richtext.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-04 20:18:15 +02:00
parent ef9c1def4e
commit dce6a8bef7
7 changed files with 282 additions and 32 deletions

View file

@ -8297,6 +8297,7 @@ Sorry for the inconvenience.";
"Chat.EmptyTopicPlaceholder.Text" = "Send the first message to\nstart this topic.";
"Chat.Message.TopicAuthorBadge" = "Topic Author";
"Chat.RichText.ShowMore" = "Show more";
"Chat.PanelRestartTopic" = "Restart Topic";
"Chat.PanelTopicClosedText" = "The topic is closed by admin";

View file

@ -396,3 +396,32 @@ Tapping a fragment-only link (`[Jump](#section)`) inside a rich-data bubble scro
- **A fragment-only URL (`#…`, empty base) is always intercepted** — never opened as an external URL. If it resolves → scroll; if not (missing or empty anchor) → no-op (press-highlight only). A real URL carrying a fragment (`https://x.com/p#s`, non-empty base) keeps the unchanged external-URL handling.
- **The expansion loop terminates** via a progress guard (`lastExpandedPendingDetailsIndex == collapsedIndex` → give up): each relayout pass either resolves+scrolls (clearing pending) or advances to a strictly deeper collapsed `<details>`.
- **No `activate:` on the anchor tap action** (unlike external-URL taps): anchor scrolling is local and instant, so the link-loading shimmer (`makeActivate`) would falsely imply network activity. The press-highlight `rects` are still passed.
## "Show more" for partial rich messages (on-demand full page)
A server-sent rich message can arrive **partial** when the content is long: the `RichMessage` `isPartial` flag maps to `instantPage.isComplete == false`. The bubble then renders the partial page plus an inline **"Show more"** link; tapping it fetches the full page (once) and expands the bubble in place.
### Data model
- `RichTextMessageAttribute` (`SyncCore_RichTextMessageAttribute.swift`) carries the partial `instantPage` **and** an optional `fullInstantPage: InstantPage?` (nil until fetched). The partial page is **never replaced** — the full page is stored alongside it (encoded/decoded; both in `==`).
- `engine.messages.requestFullRichText(id:)` (`TelegramEngineMessages.swift`) requests `messages.getRichMessage`, then `transaction.updateMessage(id,…)` sets the existing attribute's `fullInstantPage` to the fetched complete page (keeping `instantPage`), and returns the updated attribute. It yields `.single(nil)` for non-Cloud ids and on network failure (no postbox change).
- The seed-config merge (`SyncCore_StandaloneAccountTransaction.swift`) preserves a previously-fetched `fullInstantPage` if a later server update for the same message arrives without one (same partial `instantPage`).
### Where things live
| File | Responsibility |
|---|---|
| `…/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift` | The `fullInstantPage` field (init / encode / decode / `==`). |
| `…/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift` | `requestFullRichText(id:)` — fetch + `updateMessage` to fill `fullInstantPage`. |
| `…/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift` | Seed-config merge preserving a fetched `fullInstantPage` across later updates. |
| `…/Chat/ChatMessageRichDataBubbleContentNode/…` | The "Show more" link (layout, tap via `tapActionAtPoint` `.custom` + `updateTouchesAtPoint` highlight, `TextLoadingEffectView` shimmer), the node-local expand state, the effective-page selection, and the downward-expand. |
| `Telegram/Telegram-iOS/en.lproj/Localizable.strings` | `Chat.RichText.ShowMore` = "Show more" (→ `strings.Chat_RichText_ShowMore`). |
### Non-obvious invariants
- **Expand state is node-local and per-message, NOT derived from the attribute.** `showMoreExpanded: (messageId, value)?` is snapshotted at layout time and resolved against the current `item.message.id`, so **every fresh display of a message starts collapsed (partial)** even when its attribute already carries a cached `fullInstantPage`; only an in-place tap expands, and that expansion survives same-message relayouts. Resolving against the message id makes any *other* message collapse automatically (no stale-snapshot bug, no manual reset).
- **The bubble renders `(showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage`** — the full page only while expanded — in both the webpage build and `layoutInstantPageV2`. `scrollToAnchor` resolves anchors against the same effective page.
- **The link shows only when `!showMoreExpanded` AND `!attribute.instantPage.isComplete`** (plus the original gates: not streaming via `TypingDraftMessageAttribute`, `id.namespace == .Cloud` since `requestFullRichText` is a no-op otherwise, and not a preview / `.messageOptions` context). The date/status trails the link's line by substituting the link frame for the last-text-line frame (see the status-node section).
- **`showMoreExpanded` is part of BOTH layout caches.** It is in the `currentPageLayout` cache key **and** the `pageView` content key (`pageViewMessageKey`). This is required because the cached-expand path (full page already on the attribute) performs **no postbox write**, so `stableVersion` does not bump — without the key, the cached partial layout/content would shadow the expand.
- **Tap (`activateShowMore`):** if `fullInstantPage` is already cached → set expanded + `requestMessageUpdate` immediately (no network, no shimmer); otherwise shimmer the link and fetch, expanding only once the full page lands. Guards against a second in-flight request and against re-expanding.
- **Expand grows the bubble downward in screen space** (top fixed) via `info?.setInvertOffsetDirection()` on the `ListViewItemApply` in the apply closure, fired only on the `appliedShowMoreExpanded → showMoreExpanded` transition (never on first apply). Same mechanism as `ChatMessageInteractiveFileNode`'s audio-transcription expand and the text/fact-check bubbles; the ListView clamps it to what fits.

View file

@ -1020,7 +1020,7 @@ public func inputRichTextAttributeFromText(context: AccountContext, text: String
guard let webpage = markdownWebpage(context: context, file: nil, data: data), case let .Loaded(content) = webpage.content, let instantPage = content.instantPage else {
return nil
}
return RichTextMessageAttribute(instantPage: instantPage._parse())
return RichTextMessageAttribute(instantPage: instantPage._parse(), fullInstantPage: nil)
}
// MARK: - Markdown classification (entity-expressible vs. rich layout)

View file

@ -4,6 +4,7 @@ import TelegramApi
public class RichTextMessageAttribute: MessageAttribute, Equatable {
public let instantPage: InstantPage
public var fullInstantPage: InstantPage?
public var associatedPeerIds: [PeerId] {
return []
@ -13,20 +14,27 @@ public class RichTextMessageAttribute: MessageAttribute, Equatable {
return []
}
public init(instantPage: InstantPage) {
public init(instantPage: InstantPage, fullInstantPage: InstantPage?) {
self.instantPage = instantPage
self.fullInstantPage = fullInstantPage
}
required public init(decoder: PostboxDecoder) {
self.instantPage = decoder.decodeObjectForKey("instantPage", decoder: { InstantPage(decoder: $0) }) as! InstantPage
self.fullInstantPage = decoder.decodeObjectForKey("fullInstantPage", decoder: { InstantPage(decoder: $0) }) as? InstantPage
}
public func encode(_ encoder: PostboxEncoder) {
encoder.encodeObject(self.instantPage, forKey: "instantPage")
if let fullInstantPage = self.fullInstantPage {
encoder.encodeObject(fullInstantPage, forKey: "fullInstantPage")
} else {
encoder.encodeNil(forKey: "fullInstantPage")
}
}
public static func ==(lhs: RichTextMessageAttribute, rhs: RichTextMessageAttribute) -> Bool {
return lhs.instantPage == rhs.instantPage
return lhs.instantPage == rhs.instantPage && lhs.fullInstantPage == rhs.fullInstantPage
}
}
@ -48,7 +56,7 @@ extension RichTextMessageAttribute {
let isRtl = (richMessage.flags & (1 << 0)) != 0
let isPartial = (richMessage.flags & (1 << 1)) != 0
let instantPage = InstantPage(blocks: richMessage.blocks.map({ InstantPageBlock(apiBlock: $0) }), media: media, isComplete: !isPartial, rtl: isRtl, url: "", views: nil)
self.init(instantPage: instantPage)
self.init(instantPage: instantPage, fullInstantPage: nil)
}
}

View file

@ -162,20 +162,11 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = {
updated.append(previousDerivedData)
}
}
if let previousRichText, previousRichText.instantPage.isComplete {
if let previousRichText, previousRichText.fullInstantPage != nil {
for i in 0 ..< updated.count {
if let attribute = updated[i] as? RichTextMessageAttribute {
if !attribute.instantPage.isComplete {
var prefixEquals = true
if attribute.instantPage.blocks.count <= previousRichText.instantPage.blocks.count {
inner: for j in 0 ..< attribute.instantPage.blocks.count {
if attribute.instantPage.blocks[j] != attribute.instantPage.blocks[j] {
prefixEquals = false
break inner
}
}
}
if prefixEquals {
if attribute.fullInstantPage == nil {
if attribute.instantPage == previousRichText.instantPage {
updated[i] = previousRichText
}
}

View file

@ -1915,9 +1915,23 @@ public extension TelegramEngine {
peerIsForum = true
}
updatePeers(transaction: transaction, accountPeerId: account.peerId, peers: AccumulatedPeers(chats: chats, users: users))
let _ = transaction.addMessages(messages.compactMap { message -> StoreMessage? in
return StoreMessage(apiMessage: message, accountPeerId: account.peerId, peerIsForum: peerIsForum)
}, location: .Random)
if let apiMessage = messages.first, let storeMessage = StoreMessage(apiMessage: apiMessage, accountPeerId: account.peerId, peerIsForum: peerIsForum) {
transaction.updateMessage(id, update: { currentMessage in
var storeForwardInfo: StoreMessageForwardInfo?
if let forwardInfo = currentMessage.forwardInfo {
storeForwardInfo = StoreMessageForwardInfo(authorId: forwardInfo.author?.id, sourceId: forwardInfo.source?.id, sourceMessageId: forwardInfo.sourceMessageId, date: forwardInfo.date, authorSignature: forwardInfo.authorSignature, psaType: forwardInfo.psaType, flags: forwardInfo.flags)
}
var updatedAttributes = currentMessage.attributes
if let updatedRichAttribute = storeMessage.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute {
if let index = updatedAttributes.firstIndex(where: { $0 is RichTextMessageAttribute }), let previous = updatedAttributes[index] as? RichTextMessageAttribute {
updatedAttributes[index] = RichTextMessageAttribute(instantPage: previous.instantPage, fullInstantPage: updatedRichAttribute.instantPage)
}
}
return .update(StoreMessage(id: currentMessage.id, customStableId: nil, globallyUniqueId: currentMessage.globallyUniqueId, groupingKey: currentMessage.groupingKey, threadId: currentMessage.threadId, timestamp: currentMessage.timestamp, flags: StoreMessageFlags(currentMessage.flags), tags: currentMessage.tags, globalTags: currentMessage.globalTags, localTags: currentMessage.localTags, forwardInfo: storeForwardInfo, authorId: currentMessage.author?.id, text: currentMessage.text, attributes: updatedAttributes, media: currentMessage.media))
})
}
return transaction.getMessage(id)?.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute
}

View file

@ -29,7 +29,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// The synthesized webpage uses a sentinel id (namespace 0, id 0) shared across all richText
// messages, so we key cache invalidation on the message itself. When the bubble is recycled
// with a different message we must discard pageView (render context is constructor-fixed).
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32)?
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32, showMoreExpanded: Bool)?
// messageStableVersion is in the cache key because the synthesized instantPage content
// mutates between streamed AI message chunks (each chunk bumps stableVersion); without
// this, the cached layout would shadow newly-arrived content during streaming.
@ -37,6 +37,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
presentationThemeIdentity: ObjectIdentifier,
expandedDetails: [Int: Bool],
messageStableVersion: UInt32,
showMoreExpanded: Bool,
layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:]
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
@ -60,6 +61,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var displayContentsUnderSpoilers: Bool = false
private var relativeDateTimer: (timer: SwiftSignalKit.Timer, period: Int32)?
// "Show more" affordance for partial rich messages (instantPage.isComplete == false).
// Managed inline, mirroring the statusNode pattern: a bubble-owned TextNode below the page
// content, with a TextLoadingEffectView shimmer while the full-text request is in flight.
private var showMoreTextNode: TextNode?
private var showMoreLoadingView: TextLoadingEffectView?
private var requestFullRichTextDisposable: Disposable?
private var requestFullRichTextMessageId: EngineMessage.Id?
// Transient per-message expand state. The full page is shown only after the user taps "Show
// more"; tagging it with the message id means any other message starts collapsed (partial)
// every time, even if its attribute already carries a cached fullInstantPage.
private var showMoreExpanded: (messageId: EngineMessage.Id, value: Bool)?
// The expand state actually applied on the previous layout pass, used to detect the
// collapseexpand transition so the bubble can grow downward in screen space (see the
// setInvertOffsetDirection call in the apply closure). nil until the first apply.
private var appliedShowMoreExpanded: Bool?
override public var visibility: ListViewItemNodeVisibility {
didSet {
if oldValue != self.visibility {
@ -101,10 +118,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
/// the bubble is recycled with a different message/webpage (different message id).
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage) -> InstantPageV2View {
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage, showMoreExpanded: Bool) -> InstantPageV2View {
let key = (id: item.message.id, stableVersion: item.message.stableVersion, showMoreExpanded: showMoreExpanded)
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
if current.stableVersion == key.stableVersion {
if current.stableVersion == key.stableVersion && current.showMoreExpanded == key.showMoreExpanded {
return existing
}
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
@ -190,13 +207,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
deinit {
self.linkProgressDisposable?.dispose()
self.relativeDateTimer?.timer.invalidate()
self.requestFullRichTextDisposable?.dispose()
}
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let previousItem = self.item
let currentPageLayout = self.currentPageLayout
let currentExpandedDetails = self.currentExpandedDetails
let showMoreExpandedState = self.showMoreExpanded
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
let showMoreTextLayout = TextNode.asyncLayout(self.showMoreTextNode)
// Captured at main-thread, top of asyncLayoutContent. Mirrors TextBubble's
// `currentMaxGlyphCount` (TextBubble:313). The bubble's bounding size is sized
// to this revealed prefix during streaming, so it grows with the reveal rather
@ -334,6 +354,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
hadDraft = true
}
// Resolve the node-local expand state for THIS message (collapsed for any other).
let showMoreExpanded = (showMoreExpandedState?.messageId == item.message.id) ? (showMoreExpandedState?.value ?? false) : false
if let attribute = item.message.richText {
#if DEBUG && false
let instantPage = InstantPage(blocks: [.thinking(.concat([
@ -341,9 +364,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = attribute.instantPage
// Show the full page only while expanded (after a "Show more" tap); otherwise the partial.
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
#endif
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
@ -373,6 +397,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
current.boundingWidth == suggestedBoundingWidth,
current.presentationThemeIdentity == presentationThemeIdentity,
current.expandedDetails == currentExpandedDetails,
current.showMoreExpanded == showMoreExpanded,
current.messageStableVersion == currentMessageStableVersion,
current.layout.formattedDateUpdatePeriod == nil {
// Reuse the cached layout only when it has no relative `textDate`. A relative
@ -389,7 +414,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = attribute.instantPage
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
#endif
pageLayout = layoutInstantPageV2(
webpage: webpage,
@ -526,11 +551,54 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// item is itself a text item; otherwise (table/image/etc. last) the status falls
// through to the contentSize.height anchor and sits below all content.
let lastTextLine = pageLayout.flatMap(InstantPageUI.lastTextLineFrameIfLastItemIsText(in:))
let lastTextLineFrame: CGRect? = lastTextLine?.frame
var lastTextLineFrame: CGRect? = lastTextLine?.frame
// Baseline visible-text-bottom compensation. Applied whether the date trails on
// the last line or wraps onto its own line below it (0 for attachment-inflated lines,
// whose maxY already sits at the visible bottom).
let lastTextLineTrailingPadding: CGFloat = lastTextLine?.trailingBottomPadding ?? 0.0
var lastTextLineTrailingPadding: CGFloat = lastTextLine?.trailingBottomPadding ?? 0.0
// "Show more" affordance for partial rich messages: laid out as a bubble-owned text
// node below the page content. Shown only when the page is incomplete AND the user
// has not expanded it yet (showMoreExpanded == false), the message is not streaming,
// it is a Cloud message (requestFullRichText is a no-op otherwise), and we are not in
// a preview / messageOptions context. When present, the date trails the link's line
// by substituting its frame for the last-text-line frame the status machinery consumes.
var showMore = false
if let attribute = item.message.richText,
!showMoreExpanded,
!attribute.instantPage.isComplete,
!hasDraft,
item.message.id.namespace == Namespaces.Message.Cloud,
!item.presentationData.isPreview {
if let subject = item.associatedData.subject, case .messageOptions = subject {
showMore = false
} else {
showMore = true
}
}
var showMoreLayoutResult: (TextNodeLayout, () -> TextNode)?
var showMoreFramePageLocal: CGRect?
if showMore, let pageLayout {
let title = item.presentationData.strings.Chat_RichText_ShowMore
let attributedTitle = NSAttributedString(string: title, font: Font.regular(17.0), textColor: messageTheme.linkTextColor)
// The link only fits within the existing bubble width (it does not widen the
// bubble the way the status node does); the short fixed string never needs more,
// and `.end` truncation is a safe fallback for a pathologically narrow bubble.
let constrainedWidth = max(1.0, boundingSize.width - pageHorizontalInset * 2.0)
let layout = showMoreTextLayout(TextNodeLayoutArguments(attributedString: attributedTitle, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedWidth, height: 100.0)))
let showMoreTopSpacing: CGFloat = 2.0
let frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: pageLayout.contentSize.height + showMoreTopSpacing), size: layout.0.size)
showMoreLayoutResult = layout
showMoreFramePageLocal = frame
// Date trails the link line (or wraps below it if it doesn't fit) reuse the
// status machinery by substituting the link frame for the last-text-line frame.
lastTextLineFrame = frame
lastTextLineTrailingPadding = 0.0
// Ensure the bubble contains the link even when the status node is hidden. The 1.0
// is the content top rim; 6.0 the bottom breathing room used elsewhere in this file.
boundingSize.height = max(boundingSize.height, 1.0 + frame.maxY + 6.0)
}
var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode))?
if let statusType = statusType {
@ -612,12 +680,32 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)
}
return (boundingSize, { animation, _, _ in
return (boundingSize, { animation, _, info in
guard let self else {
return
}
self.item = item
// If the bubble was recycled onto a different message while a full-text
// request was in flight, cancel it so this message never shows another's
// shimmer.
if let pendingId = self.requestFullRichTextMessageId, pendingId != item.message.id {
self.requestFullRichTextDisposable?.dispose()
self.requestFullRichTextDisposable = nil
self.requestFullRichTextMessageId = nil
self.updateShowMoreLoading(false)
}
// On the collapseexpand transition (tapping "Show more"), grow the bubble
// downward in screen space (inverted list offset direction) instead of pushing
// earlier messages up matching the audio-transcription expand. The ListView
// clamps this to what fits, so "if possible" is handled for us. Only fires on a
// change, and never on the first apply (appliedShowMoreExpanded is nil).
if let appliedShowMoreExpanded = self.appliedShowMoreExpanded, appliedShowMoreExpanded != showMoreExpanded {
info?.setInvertOffsetDirection()
}
self.appliedShowMoreExpanded = showMoreExpanded
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
self.containerNode.cornerRadius = layoutConstants.image.defaultCornerRadius
@ -687,9 +775,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
ObjectIdentifier(item.presentationData.theme.theme),
self.currentExpandedDetails,
item.message.stableVersion,
showMoreExpanded,
pageLayout
)
let pageView = self.ensurePageView(item: item, webpage: pageWebpage)
let pageView = self.ensurePageView(item: item, webpage: pageWebpage, showMoreExpanded: showMoreExpanded)
pageView.update(layout: pageLayout, theme: pageTheme, animation: animation)
pageView.frame = CGRect(
origin: CGPoint(x: -1.0, y: streamingHeaderOffset),
@ -725,6 +814,30 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.pageViewMessageKey = nil
}
// "Show more" link node.
if let showMoreLayoutResult, let showMoreFramePageLocal {
let showMoreTextNode = showMoreLayoutResult.1()
if self.showMoreTextNode !== showMoreTextNode {
self.showMoreTextNode?.removeFromSupernode()
self.showMoreTextNode = showMoreTextNode
showMoreTextNode.isUserInteractionEnabled = false
self.addSubnode(showMoreTextNode)
}
// Self-coords: the 1.0 mirrors statusFrameY's container offset; the page
// content sits 1pt below the content-node top.
showMoreTextNode.frame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: 1.0 + showMoreFramePageLocal.minY), size: showMoreFramePageLocal.size)
// Keep the shimmer alive across intervening relayouts while loading.
if self.requestFullRichTextDisposable != nil, self.requestFullRichTextMessageId == item.message.id {
self.updateShowMoreLoading(true)
}
} else {
if let showMoreTextNode = self.showMoreTextNode {
self.showMoreTextNode = nil
showMoreTextNode.removeFromSupernode()
}
self.updateShowMoreLoading(false)
}
if let formattedDateUpdatePeriod = pageLayout?.formattedDateUpdatePeriod {
// Recreate the timer only when the period changes unlike the TextBubble
// reference (ChatMessageTextBubbleContentNode), which rebuilds it every apply.
@ -900,6 +1013,15 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
}
if case .tap = gesture, let showMoreTextNode = self.showMoreTextNode, showMoreTextNode.frame.contains(point) {
// Highlight rect in containerNode-local coords (the highlight overlay lives inside
// containerNode, which sits at self (1, 1); the text node is on self).
let rects = [showMoreTextNode.frame.offsetBy(dx: -1.0, dy: -1.0)]
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.activateShowMore()
}), rects: rects)
}
if case .tap = gesture, !self.displayContentsUnderSpoilers, let entityHit = self.entityForTapLocation(point), entityHit.attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil {
return ChatMessageBubbleContentTapAction(content: .custom({ [weak self] in
self?.revealSpoilers(atContentPoint: point)
@ -1113,7 +1235,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
var rects: [CGRect]?
if let point {
if let urlHit = self.urlForTapLocation(point) {
if let showMoreTextNode = self.showMoreTextNode, showMoreTextNode.frame.contains(point) {
rects = [showMoreTextNode.frame.offsetBy(dx: -1.0, dy: -1.0)]
} else if let urlHit = self.urlForTapLocation(point) {
rects = self.computeHighlightRects(item: urlHit.item, parentOffset: urlHit.parentOffset, localPoint: urlHit.localPoint)
} else if let entityHit = self.entityForTapLocation(point), self.entityTapContent(entityHit.attributes) != nil {
rects = self.computeHighlightRects(item: entityHit.item, parentOffset: entityHit.parentOffset, localPoint: entityHit.localPoint)
@ -1321,7 +1445,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
// 2. Not laid out it may be buried in a collapsed <details>. Find the path and expand
// the first collapsed details on it, then retry after the relayout (post-relayout hook).
guard let instantPage = item.message.richText?.instantPage,
let anchorExpanded = (self.showMoreExpanded?.messageId == item.message.id) ? (self.showMoreExpanded?.value ?? false) : false
guard let instantPage = item.message.richText.map({ (anchorExpanded ? $0.fullInstantPage : nil) ?? $0.instantPage }),
let path = instantPageAnchorPath(in: instantPage, name: anchor),
!path.isEmpty,
let collapsedIndex = self.pageView?.firstCollapsedDetails(forOrdinalPath: path)
@ -1344,4 +1469,86 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
self.pendingScrollAnchor = nil
self.lastExpandedPendingDetailsIndex = nil
}
// Fired by the "Show more" tap action. Expands this bubble to the full page: if the attribute
// already carries a cached fullInstantPage, expands immediately; otherwise fetches it (which
// persists it onto the message) while shimmering the link, then expands. Guards against a
// second request while one is in flight, and against re-expanding an already-expanded bubble.
private func activateShowMore() {
guard let item = self.item, let attribute = item.message.richText else {
return
}
let messageId = item.message.id
if let state = self.showMoreExpanded, state.messageId == messageId, state.value {
return
}
// Full page already cached on the attribute expand immediately, no network, no shimmer.
if attribute.fullInstantPage != nil {
self.showMoreExpanded = (messageId, true)
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
return
}
// Otherwise fetch it; keep the link visible and shimmering until it arrives.
if self.requestFullRichTextDisposable != nil {
return
}
self.requestFullRichTextMessageId = messageId
self.updateShowMoreLoading(true)
self.requestFullRichTextDisposable = (item.context.engine.messages.requestFullRichText(id: messageId)
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
guard let self else {
return
}
if result?.fullInstantPage != nil {
self.showMoreExpanded = (messageId, true)
}
self.finishShowMore()
if let item = self.item, item.message.id == messageId {
item.controllerInteraction.requestMessageUpdate(messageId, false, nil)
}
}, completed: { [weak self] in
self?.finishShowMore()
})
}
// Clears the in-flight request state and stops the shimmer. Invoked from both the request's
// `next` and `completed` handlers (the signal emits one value then completes); idempotent.
private func finishShowMore() {
self.requestFullRichTextDisposable?.dispose()
self.requestFullRichTextDisposable = nil
self.requestFullRichTextMessageId = nil
self.updateShowMoreLoading(false)
}
// Shows/hides the shimmer over the "Show more" text node. The TextLoadingEffectView masks
// itself with the text node's own range rects, so it is placed at the text node's frame in
// self-coordinates (same parent). Removing the text node also removes the shimmer.
private func updateShowMoreLoading(_ loading: Bool) {
guard let item = self.item, let showMoreTextNode = self.showMoreTextNode else {
if let loadingView = self.showMoreLoadingView {
self.showMoreLoadingView = nil
loadingView.removeFromSuperview()
}
return
}
if loading {
let loadingView: TextLoadingEffectView
if let current = self.showMoreLoadingView {
loadingView = current
} else {
loadingView = TextLoadingEffectView(frame: CGRect())
self.showMoreLoadingView = loadingView
self.view.addSubview(loadingView)
}
loadingView.frame = showMoreTextNode.frame
let color = item.message.effectivelyIncoming(item.context.account.peerId)
? item.presentationData.theme.theme.chat.message.incoming.linkTextColor
: item.presentationData.theme.theme.chat.message.outgoing.linkTextColor
let title = item.presentationData.strings.Chat_RichText_ShowMore
loadingView.update(color: color, textNode: showMoreTextNode, range: NSRange(location: 0, length: (title as NSString).length))
} else if let loadingView = self.showMoreLoadingView {
self.showMoreLoadingView = nil
loadingView.removeFromSuperview()
}
}
}