From dce6a8bef7ad0541fae36015e039ee761d641b07 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 4 Jun 2026 20:18:15 +0200 Subject: [PATCH] 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) --- .../Telegram-iOS/en.lproj/Localizable.strings | 1 + docs/instantpage-richtext.md | 29 +++ .../BrowserUI/Sources/BrowserMarkdown.swift | 2 +- .../SyncCore_RichTextMessageAttribute.swift | 14 +- ...yncCore_StandaloneAccountTransaction.swift | 15 +- .../Messages/TelegramEngineMessages.swift | 20 +- ...ChatMessageRichDataBubbleContentNode.swift | 233 +++++++++++++++++- 7 files changed, 282 insertions(+), 32 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 15ee958e27..18046ffc30 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -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"; diff --git a/docs/instantpage-richtext.md b/docs/instantpage-richtext.md index c3e1f9fed9..24072ce44f 100644 --- a/docs/instantpage-richtext.md +++ b/docs/instantpage-richtext.md @@ -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 `
`. - **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. diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 80fb481859..cd2de7521f 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -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) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift index 0bcf72afdf..95ef406146 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_RichTextMessageAttribute.swift @@ -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) } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift index ea3445d826..32b775a717 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift @@ -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 } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index ab4bdf762d..babcdf2ff3 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -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 } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index 4a562f581e..74839546bd 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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
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 + // collapse→expand 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 collapse→expand 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
. 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() + } + } }