-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathChatMessageRichDataBubbleContentNode.swift
More file actions
1862 lines (1715 loc) · 108 KB
/
Copy pathChatMessageRichDataBubbleContentNode.swift
File metadata and controls
1862 lines (1715 loc) · 108 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import SwiftSignalKit
import AccountContext
import ChatMessageBubbleContentNode
import ChatMessageDateAndStatusNode
import ChatMessageItemCommon
import ChatControllerInteraction
import InstantPageUI
import TextFormat
import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
import StreamingTextReveal
import ShimmeringLinkNode
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
}
private enum ResolvedRichDataPageKey: Equatable {
case pendingEdit(attribute: ObjectIdentifier, page: ObjectIdentifier)
case translated(language: String, attribute: ObjectIdentifier, page: ObjectIdentifier)
case original(attribute: ObjectIdentifier, page: ObjectIdentifier)
}
private struct ResolvedRichDataContent {
let instantPage: InstantPage
let originalAttribute: RichTextMessageAttribute?
let key: ResolvedRichDataPageKey
let isTranslated: Bool
let isTranslating: Bool
}
private let containerNode: ContainerNode
public var statusNode: ChatMessageDateAndStatusNode?
// `init()` may run off the main thread; UIView construction must happen on the main thread.
// The page view is built lazily inside the apply closure (always main-thread) via ensurePageView().
private var pageView: InstantPageV2View?
// Tracks the message (id + stableVersion) baked into the current pageView's render context.
// 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, pendingEditKey: ObjectIdentifier?, richPageKey: ResolvedRichDataPageKey, 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.
private var currentPageLayout: (boundingWidth: CGFloat,
presentationThemeIdentity: ObjectIdentifier,
baseFontSize: CGFloat,
expandedDetails: [Int: Bool],
messageStableVersion: UInt32,
pendingEditKey: ObjectIdentifier?,
richPageKey: ResolvedRichDataPageKey,
showMoreExpanded: Bool,
layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:]
// Intra-message anchor scroll that is waiting on a collapsed <details> to expand + relayout.
private var pendingScrollAnchor: String?
// Progress guard: the details index expanded on the previous pending pass.
private var lastExpandedPendingDetailsIndex: Int?
private var linkProgressDisposable: Disposable?
private var linkProgressRects: [CGRect]?
private var linkHighlightingNode: LinkHighlightingNode?
private var linkProgressView: TextLoadingEffectView?
private var shimmeringNode: ShimmeringLinkNode?
private var shimmeringNodeIsSkeleton: Bool = false
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap?
// Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick
// compares the revealed prefix's height at this cursor vs the new cursor to decide when
// to request a full bubble re-layout (so the bubble grows with the reveal).
private var lastAppliedRevealedCount: Int = 0
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 {
self.updatePageViewVisibilityRect()
}
}
}
// Pushes the current `visibility` sub-rect into `pageView.visibilityRect`, translated into the
// page view's coordinate space (the page view sits at the top of the bubble; no header offset).
// Re-invoked from the apply closure after `pageView.frame` is set, because the pageView's
// y-origin and size can change across streamed chunks (content growth) without a `visibility`
// change, which would otherwise leave the animation-gating rect stale.
private func updatePageViewVisibilityRect() {
guard let pageView = self.pageView else {
return
}
switch self.visibility {
case .none:
pageView.visibilityRect = nil
case let .visible(_, subRect):
var rect = subRect
rect.origin.x = 0.0
rect.size.width = 10000.0
rect.origin.y -= pageView.frame.minY
pageView.visibilityRect = rect
}
}
required public init() {
self.containerNode = ContainerNode()
self.containerNode.clipsToBounds = true
super.init()
self.addSubnode(self.containerNode)
}
private static func resolvedRichDataContent(item: ChatMessageBubbleContentItem, showMoreExpanded: Bool) -> ResolvedRichDataContent? {
if let attribute = item.attributes.updatingMedia?.richText {
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .pendingEdit(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(instantPage)),
isTranslated: false,
isTranslating: false
)
}
guard let attribute = item.message.richText else {
return nil
}
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
var canDisplayTranslation = isIncoming
if let subject = item.associatedData.subject, case .messageOptions = subject {
canDisplayTranslation = false
}
if canDisplayTranslation, let translateToLanguage = item.associatedData.translateToLanguage {
if let translation = item.message.attributes.first(where: { ($0 as? TranslationMessageAttribute)?.toLang == translateToLanguage }) as? TranslationMessageAttribute, let instantPage = translation.instantPage {
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .translated(language: translateToLanguage, attribute: ObjectIdentifier(translation), page: ObjectIdentifier(instantPage)),
isTranslated: true,
isTranslating: false
)
} else {
return ResolvedRichDataContent(
instantPage: attribute.instantPage,
originalAttribute: attribute,
key: .original(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(attribute.instantPage)),
isTranslated: false,
isTranslating: true
)
}
}
let instantPage = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
return ResolvedRichDataContent(
instantPage: instantPage,
originalAttribute: attribute,
key: .original(attribute: ObjectIdentifier(attribute), page: ObjectIdentifier(instantPage)),
isTranslated: false,
isTranslating: false
)
}
/// 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, richPageKey: ResolvedRichDataPageKey, showMoreExpanded: Bool) -> InstantPageV2View {
let key = (id: item.message.id, stableVersion: item.message.stableVersion, pendingEditKey: (item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) }), richPageKey: richPageKey, showMoreExpanded: showMoreExpanded)
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
if current.stableVersion == key.stableVersion && current.pendingEditKey == key.pendingEditKey && current.richPageKey == key.richPageKey && current.showMoreExpanded == key.showMoreExpanded {
return existing
}
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
// the existing render context; the subsequent pageView.update(layout:) call diffs item
// views by stable id (content blocks keep their ids, so their views and in-flight
// reveal state persist; only added/removed blocks change). This replaces the old
// wholesale rebuild and eliminates the per-chunk full-text-then-mask flash.
existing.renderContext?.updateContent(webpage: webpage)
self.pageViewMessageKey = key
return existing
}
self.pageView?.removeFromSuperview()
self.pageView = nil
// Capture only the MessageReference (value type) — the closures are retained on the
// render context which is owned by the V2View, so we must avoid making them retain
// the bubble (`self`) or the message indirectly via `item`.
let messageReference = MessageReference(item.message)
let policyContext = item.context
let autoDownloadSettings = item.controllerInteraction.automaticMediaDownloadSettings
let autoDownloadPeerType = item.associatedData.automaticDownloadPeerType
let autoDownloadNetworkType = item.associatedData.automaticDownloadNetworkType
let autoDownloadContactsPeerIds = item.associatedData.contactsPeerIds
let messageAuthorPeerId = item.message.author?.id
let messagePeerId = item.message.id.peerId
let renderContext = InstantPageV2RenderContext(
context: item.context,
webpage: webpage,
sourceLocation: InstantPageSourceLocation(userLocation: .peer(messagePeerId), peerType: autoDownloadPeerType),
imageReference: { image in
return ImageMediaReference.message(message: messageReference, media: image)
},
fileReference: { file in
return FileMediaReference.message(message: messageReference, media: file)
},
present: { [weak self] controller, args in
self?.item?.controllerInteraction.presentController(controller, args)
},
push: { [weak self] controller in
self?.item?.controllerInteraction.navigationController()?.pushViewController(controller)
},
openUrl: { [weak self] urlItem in
self?.openInstantPageUrl(urlItem)
},
baseNavigationController: { [weak self] in
self?.item?.controllerInteraction.navigationController()
},
shouldAutoDownloadImage: { image in
return shouldDownloadMediaAutomatically(settings: autoDownloadSettings, peerType: autoDownloadPeerType, networkType: autoDownloadNetworkType, authorPeerId: messageAuthorPeerId, contactsPeerIds: autoDownloadContactsPeerIds, media: image)
},
shouldAutoDownloadFile: { file in
return shouldDownloadMediaAutomatically(settings: autoDownloadSettings, peerType: autoDownloadPeerType, networkType: autoDownloadNetworkType, authorPeerId: messageAuthorPeerId, contactsPeerIds: autoDownloadContactsPeerIds, media: file)
},
shouldAutoplayVideo: { file in
let enabled = file.isAnimated ? policyContext.sharedContext.energyUsageSettings.autoplayGif : policyContext.sharedContext.energyUsageSettings.autoplayVideo
guard enabled else { return false }
return policyContext.engine.resources.completedResourcePath(id: EngineMediaResource.Id(file.resource.id)) != nil
},
message: messageReference
)
let view = InstantPageV2View(renderContext: renderContext)
self.pageView = view
self.pageViewMessageKey = key
self.containerNode.view.addSubview(view)
view.detailsTapped = { [weak self] index in
guard let self else { return }
let current = self.currentExpandedDetails[index] ?? self.defaultExpanded(forDetailsIndex: index)
self.currentExpandedDetails[index] = !current
if let item = self.item {
item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil)
}
}
return view
}
/// True when the rendered page is the message's primary (non-translated, non-full,
/// non-pending-edit) InstantPage — the only rendering whose checkbox paths are safe to
/// edit — AND the message is editable.
private func checkboxesInteractive(item: ChatMessageBubbleContentItem, resolved: ResolvedRichDataContent) -> Bool {
// `.original` (server state) and `.pendingEdit` (an in-flight edit) are both eligible —
// keeping checkboxes live during the pending round-trip lets the user toggle several boxes
// in a row. `.translated` is inert, as is the translation-pending fallback (which reports an
// `.original` key over the genuine original page but with `isTranslating == true`).
switch resolved.key {
case .original, .pendingEdit:
break
case .translated:
return false
}
if resolved.isTranslating {
return false
}
// The primary page is the resolved attribute's `instantPage` (class identity). The show-more
// (`fullInstantPage`) rendering carries the same key but a different page object, so an
// identity check excludes it. `originalAttribute` is Optional (it is the pending edit's
// attribute in the `.pendingEdit` case).
guard let attribute = resolved.originalAttribute, resolved.instantPage === attribute.instantPage else {
return false
}
return item.controllerInteraction.canEditMessageRichText(item.message)
}
private func defaultExpanded(forDetailsIndex index: Int) -> Bool {
guard let layout = self.currentPageLayout?.layout else { return false }
func search(_ items: [InstantPageV2LaidOutItem]) -> Bool? {
for item in items {
if case let .details(d) = item {
if d.index == index {
return d.defaultExpanded
}
// Recurse into an expanded parent's body so NESTED details indices resolve too;
// the flat top-level scan missed them, leaving the toggle's "current state"
// computation wrong for a nested details whose model default is expanded.
if let inner = d.innerLayout, let found = search(inner.items) {
return found
}
}
}
return nil
}
return search(layout.items) ?? false
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
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
// than being final-sized from the first chunk.
let currentMaxGlyphCount: Int? = self.textRevealController?.currentGlyphCount
return { [weak self] item, layoutConstants, _, _, _, _ in
// Structural detector (model-only): does the effective rich page end with full-width
// visual media? This is emitted BEFORE the page is laid out, so it inspects the block
// model rather than laid-out items. Its only job is to push non-inline reactions outside
// the bubble (the overlaid pill can't host multi-row reactions). The authoritative
// full-width placement decision happens later, in the layout phase.
var wantsReactionsOutside = false
if let attribute = (item.attributes.updatingMedia.map(\.richText) ?? item.message.richText) {
let showMoreExpanded = (showMoreExpandedState?.messageId == item.message.id) ? (showMoreExpandedState?.value ?? false) : false
let page = (showMoreExpanded ? attribute.fullInstantPage : nil) ?? attribute.instantPage
if let lastBlock = page.blocks.last, richDataBlockEndsWithVisualMedia(lastBlock) {
let reactions = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId))
let hasReactions = !(reactions?.reactions.isEmpty ?? true)
let inline = shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions)
wantsReactionsOutside = hasReactions && !inline
}
}
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 8.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none, wantsReactionsOutside: wantsReactionsOutside)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
let suggestedBoundingWidth: CGFloat = constrainedSize.width
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
var pageLayout: InstantPageV2Layout?
// Built alongside pageLayout so the apply closure can hand it to ensurePageView.
var pageWebpage: TelegramMediaWebpage?
// Horizontal text inset baked into the InstantPage layout. The pageView sits at
// self-x 0 (containerNode at 1, pageView at -1 inside it), so the page's text
// left edge in the status node's coordinate space is exactly this value. Used as
// the status node's left edge + side inset, mirroring TextBubble's bubbleInsets.
let pageHorizontalInset: CGFloat = 11.0
let isDark = item.presentationData.theme.theme.overallDarkAppearance
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
let messageTheme = isIncoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
var underlineLinks = true
if !messageTheme.primaryTextColor.isEqual(messageTheme.linkTextColor) {
underlineLinks = false
}
let _ = underlineLinks
let author = item.message.author
let mainColor: UIColor
var secondaryColor: UIColor? = nil
var tertiaryColor: UIColor? = nil
let nameColors: PeerNameColors.Colors?
switch author?.nameColor {
case let .preset(nameColor):
nameColors = item.context.peerNameColors.get(nameColor, dark: item.presentationData.theme.theme.overallDarkAppearance)
case let .collectible(collectibleColor):
nameColors = collectibleColor.peerNameColors(dark: item.presentationData.theme.theme.overallDarkAppearance)
default:
nameColors = nil
}
let codeBlockBackgroundColor: UIColor
let codeBlockTitleColor: UIColor
let codeBlockAccentColor: UIColor
if !isIncoming {
mainColor = messageTheme.accentTextColor
if let _ = nameColors?.secondary {
secondaryColor = .clear
}
if let _ = nameColors?.tertiary {
tertiaryColor = .clear
}
if item.presentationData.theme.theme.overallDarkAppearance {
codeBlockTitleColor = .white
codeBlockAccentColor = UIColor(white: 1.0, alpha: 0.5)
} else {
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
}
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
} else {
let authorNameColor = nameColors?.main
secondaryColor = nameColors?.secondary
tertiaryColor = nameColors?.tertiary
if let authorNameColor {
mainColor = authorNameColor
} else {
mainColor = messageTheme.accentTextColor
}
codeBlockTitleColor = mainColor
codeBlockAccentColor = mainColor
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
}
let _ = secondaryColor
let _ = tertiaryColor
let _ = codeBlockTitleColor
let _ = codeBlockAccentColor
// Rich text is a message bubble like any other, so its typography follows
// Settings > Appearance > Text Size. The sizes below are authored against the
// `.regular` step (17.0pt), so scaling the finished theme by
// baseDisplaySize / 17.0 leaves that step identical and moves every other one.
let baseFontSize = item.presentationData.fontSize.baseDisplaySize
let textCategories = InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
codeBlock: InstantPageTextAttributes(font: InstantPageFont(style: .monospace, size: 14.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
)
let pageTheme = InstantPageTheme(
type: isDark ? .dark : .light,
pageBackgroundColor: .clear,
textCategories: textCategories,
serif: false,
codeBlockBackgroundColor: codeBlockBackgroundColor,
linkColor: messageTheme.linkTextColor,
textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1),
linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1),
markerColor: UIColor(rgb: 0xfef3bc),
panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
panelPrimaryColor: messageTheme.primaryTextColor,
panelSecondaryColor: messageTheme.secondaryTextColor,
panelAccentColor: messageTheme.accentTextColor,
tableBorderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.25) : UIColor(white: 0.0, alpha: 0.1),
tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
separatorColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3),
secondaryControlColor: messageTheme.secondaryTextColor.mixedWith(mainColor.withMultipliedAlpha(0.2), alpha: 0.3),
quoteAccentColor: mainColor
)
// withUpdatedFontStyles multiplies lineSpacingFactor rather than replacing it,
// so 1.0 keeps each category's own factor; forceSerif: false keeps the
// sans/serif split declared above.
.withUpdatedFontStyles(sizeMultiplier: baseFontSize / 17.0, lineSpacingFactor: 1.0, forceSerif: false)
var hasDraft = false
if item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
hasDraft = true
}
var hadDraft = false
if let previousItem, previousItem.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
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
let resolvedContent = ChatMessageRichDataBubbleContentNode.resolvedRichDataContent(item: item, showMoreExpanded: showMoreExpanded)
if let resolvedContent {
#if DEBUG && false
let instantPage = InstantPage(blocks: [.thinking(.concat([
.textCustomEmoji(fileId: 5384559872899555845, alt: "a"),
.plain("Thinking...")
]))], media: [:], isComplete: true, rtl: false, url: "", views: nil)
#else
let instantPage = resolvedContent.instantPage
#endif
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
hash: 0,
type: nil,
websiteName: nil,
title: nil,
text: nil,
embedUrl: nil,
embedType: nil,
embedSize: nil,
duration: nil,
author: nil,
isMediaLargeByDefault: nil,
imageIsVideoCover: false,
image: nil,
file: nil,
story: nil,
attributes: [],
instantPage: instantPage
)))
pageWebpage = webpage
let presentationThemeIdentity = ObjectIdentifier(item.presentationData.theme.theme)
let currentMessageStableVersion = item.message.stableVersion
let currentPendingEditKey = (item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) })
if let current = currentPageLayout,
current.boundingWidth == suggestedBoundingWidth,
current.presentationThemeIdentity == presentationThemeIdentity,
current.baseFontSize == baseFontSize,
current.expandedDetails == currentExpandedDetails,
current.showMoreExpanded == showMoreExpanded,
current.messageStableVersion == currentMessageStableVersion,
current.pendingEditKey == currentPendingEditKey,
current.richPageKey == resolvedContent.key,
current.layout.formattedDateUpdatePeriod == nil {
// Reuse the cached layout only when it has no relative `textDate`. A relative
// date's formatted string ("N minutes ago") is baked into the laid-out text at
// layout time, and none of the cache-key inputs change as wall-clock advances —
// so reusing it would freeze the date and defeat the refresh timer (which fires
// `requestFullUpdate` precisely to re-run `layoutInstantPageV2` → `formatDate`).
// Forcing a recompute for relative-date pages keeps the timer's tick visible.
pageLayout = current.layout
} else {
pageLayout = layoutInstantPageV2(
webpage: webpage,
instantPage: instantPage,
userLocation: .other,
boundingWidth: suggestedBoundingWidth - 2.0,
horizontalInset: pageHorizontalInset,
theme: pageTheme,
strings: item.presentationData.strings,
dateTimeFormat: item.presentationData.dateTimeFormat,
cachedMessageSyntaxHighlight: nil,
expandedDetails: currentExpandedDetails,
fitToWidth: true,
computeRevealCharacterRects: hasDraft || hadDraft
)
}
}
// Cost map computed here (not in apply) so we can size the bubble to the
// revealed prefix this layout pass. Mirrors TextBubble's clippedGlyphCountLayout.
let revealCostMap: InstantPageV2RevealCostMap? = (hasDraft || hadDraft) ? pageLayout?.computeRevealCostMap() : nil
let revealedGlyphCount: Int? = (hasDraft || hadDraft) ? (currentMaxGlyphCount ?? 0) : nil
if let pageLayout {
let effectiveSize: CGSize
if let costMap = revealCostMap, let glyphCount = revealedGlyphCount {
effectiveSize = costMap.revealedContentSize(revealedCount: glyphCount, layout: pageLayout)
} else {
effectiveSize = pageLayout.contentSize
}
boundingSize.width = effectiveSize.width
boundingSize.height = effectiveSize.height
}
// Authoritative detector: the bottom-most laid-out item is full-width visual media,
// so the status becomes an image-style pill overlaid on it (no reserved strip).
// Captured by the nested measure/apply closures below.
let mediaStatusFrame: CGRect? = pageLayout.flatMap(lastFullWidthMediaFrame(in:))
// The hardcoded "Thinking…" header was removed in favor of server-sent
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
// header strip anymore, so the page content starts at the top of the bubble.
let streamingHeaderOffset: CGFloat = 0.0
if hasDraft {
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
// max() in the measure closure below — but that branch is gated by
// `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim
// past `revealedContentSize.height` (= bounds.maxY + closingPad). Without
// this, descenders of the last revealed line sit cramped against the
// bubble's bottom edge and the bubble visibly grows by 6pt when streaming
// ends and the status node fades in. 6pt matches the constant inside the
// status max() (which itself tracks `TextBubble`'s `bubbleInsets.bottom`).
// `hadDraft && !hasDraft` (the finalize pass) doesn't need this because
// `!hasDraft` re-enables the status max(), which supplies the inset for it.
boundingSize.height += 6.0
}
let message = item.message
let incoming = isIncoming
var edited = false
if item.attributes.updatingMedia != nil {
edited = true
}
var viewCount: Int?
var dateReplies = 0
var starsCount: Int64?
var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: item.associatedData.accountPeer, message: item.topMessage)
if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) {
dateReactionsAndPeers = ([], [])
}
for attribute in item.message.attributes {
if let attribute = attribute as? EditedMessageAttribute {
edited = !attribute.isHidden
} else if let attribute = attribute as? ViewCountMessageAttribute {
viewCount = attribute.count
} else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation {
if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info {
dateReplies = Int(attribute.count)
}
} else if let attribute = attribute as? PaidStarsMessageAttribute, item.message.id.peerId.namespace == Namespaces.Peer.CloudChannel {
starsCount = attribute.stars.value
}
}
let dateFormat: MessageTimestampStatusFormat
if item.presentationData.isPreview {
dateFormat = .full
} else if let subject = item.associatedData.subject, case .messageOptions = subject {
dateFormat = .minimal
} else {
dateFormat = .regular
}
let dateText = stringForMessageTimestampStatus(context: item.context, message: EngineMessage(item.message), dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData)
let statusType: ChatMessageDateAndStatusType?
var displayStatus = false
switch position {
case let .linear(_, neighbor):
if case .None = neighbor {
displayStatus = true
} else if case .Neighbour(true, _, _) = neighbor {
displayStatus = true
}
default:
break
}
if case let .customChatContents(contents) = item.associatedData.subject {
if case .hashTagSearch = contents.kind {
displayStatus = true
} else {
displayStatus = false
}
} else if !item.presentationData.chatBubbleCorners.hasTails {
displayStatus = false
} else if case let .messageOptions(_, _, info) = item.associatedData.subject, case let .link(link) = info, link.isCentered {
displayStatus = false
}
if displayStatus {
let outgoingStatus: ChatMessageDateAndStatusOutgoingType
if message.flags.contains(.Failed) {
outgoingStatus = .Failed
} else if (message.flags.isSending && !message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil {
outgoingStatus = .Sending
} else {
outgoingStatus = .Sent(read: item.read)
}
if mediaStatusFrame != nil {
statusType = incoming ? .ImageIncoming : .ImageOutgoing(outgoingStatus)
} else {
statusType = incoming ? .BubbleIncoming : .BubbleOutgoing(outgoingStatus)
}
} else {
statusType = nil
}
// Only trail the status inline with the last text line when the bottom-most page
// 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:))
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).
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 = resolvedContent?.originalAttribute,
resolvedContent?.isTranslated != true,
resolvedContent?.isTranslating != true,
!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(item.presentationData.fontSize.baseDisplaySize), 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 {
var isReplyThread = false
if case .replyThread = item.chatLocation {
isReplyThread = true
}
// Measure trailing extent from the line's actual visible RIGHT EDGE (after
// alignment, in page coords) — not just its intrinsic width. A right-aligned
// or RTL last line has `lineWidth` worth of glyphs but sits all the way at
// the right text inset (lineFrame.maxX == text.frame.minX + textItem.width).
// Feeding the status node just `lineWidth` would let the trail/wrap decision
// place the date inline with the line — on top of it. `pageHorizontalInset`
// is the offset between page-coords and status-node-local coords (the status
// node sits at x=pageHorizontalInset in self, and pageView sits at self-x 0).
let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput
if mediaStatusFrame != nil {
// Overlaid pill: reactions live outside the bubble. Inline reactions, if any,
// render inside the pill via reactionSettings — mirroring media messages.
let inlineReactionSettings = shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions) ? ChatMessageDateAndStatusNode.StandaloneReactionSettings() : nil
dateLayoutInput = .standalone(reactionSettings: item.presentationData.isPreview ? nil : inlineReactionSettings)
} else {
let trailingWidthToMeasure: CGFloat = lastTextLineFrame.map { $0.maxX - pageHorizontalInset } ?? 10000.0
dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: EngineMessage(item.message), isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false))
}
statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments(
context: item.context,
presentationData: item.presentationData,
edited: edited && !item.presentationData.isPreview,
impressionCount: !item.presentationData.isPreview ? viewCount : nil,
dateText: dateText,
type: statusType,
layoutInput: dateLayoutInput,
constrainedSize: CGSize(width: boundingSize.width, height: .greatestFiniteMagnitude),
availableReactions: item.associatedData.availableReactions,
savedMessageTags: item.associatedData.savedMessageTags,
// Empty the status node's own reactions exactly when they are externalized
// (wantsReactionsOutside), NOT when the pill is shown — otherwise a structural
// "externalize" that the layout declines to pillify (e.g. a narrow collage cell)
// would render reactions both inline here AND in the external buttons node. When
// the pill IS shown, its `.standalone` input renders no reaction list regardless.
reactions: (item.presentationData.isPreview || wantsReactionsOutside) ? [] : dateReactionsAndPeers.reactions,
reactionPeers: wantsReactionsOutside ? [] : dateReactionsAndPeers.peers,
displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser,
areReactionsTags: item.topMessage.areReactionsTags(accountPeerId: item.context.account.peerId),
areStarReactionsEnabled: item.associatedData.areStarReactionsEnabled,
messageEffect: item.topMessage.messageEffect(availableMessageEffects: item.associatedData.availableMessageEffects),
replyCount: dateReplies,
starsCount: starsCount,
isPinned: item.message.tags.contains(.pinned) && (!item.associatedData.isInPinnedListMode || isReplyThread),
hasAutoremove: item.message.isSelfExpiring,
canViewReactionList: canViewMessageReactionList(message: EngineMessage(item.topMessage)),
animationCache: item.controllerInteraction.presentationContext.animationCache,
animationRenderer: item.controllerInteraction.presentationContext.animationRenderer
))
}
if let statusSuggestedWidthAndContinue, !hasDraft, mediaStatusFrame == nil {
// Mirrors TextBubble: max(contentWidth, statusWidth + sideInsets), where
// sideInsets = left + right text inset (= pageHorizontalInset on each side).
// Skipped for the overlaid pill — the media already defines the bubble width.
boundingSize.width = max(boundingSize.width, statusSuggestedWidthAndContinue.0 + pageHorizontalInset * 2.0)
}
return (boundingSize.width, { boundingWidth in
// Non-pill: pass `boundingWidth - sideInsets` (mirrors TextBubble) so the
// right-aligned date lands at the right text inset. For the overlaid pill,
// pass the status node's own suggested width so its internal `leftOffset`
// (= passedWidth - layoutSize.width) is 0 — otherwise the date is shoved right
// of its backdrop pill (the pill is anchored at the node's own bounds). This
// mirrors ChatMessageInteractiveMediaNode, which calls the continue closure with
// the suggested width for the standalone image status.
let statusContinueWidth: CGFloat = mediaStatusFrame != nil ? (statusSuggestedWidthAndContinue?.0 ?? 0.0) : (boundingWidth - pageHorizontalInset * 2.0)
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(statusContinueWidth)
if let statusSizeAndApply, !hasDraft, mediaStatusFrame == nil {
// Status node anchor Y in the content node's space — mirrors the apply
// closure below.
let statusAnchorY: CGFloat
if let lastTextLineFrame {
// The renderer draws the baseline at the line frame's maxY, so the
// visible text sits `trailingBottomPadding` below it. Apply that pad
// whether the date trails on the line OR wraps onto its own line below:
// in both cases the date should reference the visible text bottom, not
// the baseline (mirrors TextBubble, whose status anchors at the text
// frame's maxY). Without it the wrapped date crowded the last line.
statusAnchorY = 1.0 + lastTextLineFrame.maxY + lastTextLineTrailingPadding + streamingHeaderOffset
} else if let pageLayout {
statusAnchorY = 1.0 + pageLayout.contentSize.height + streamingHeaderOffset
} else {
statusAnchorY = 1.0 + streamingHeaderOffset
}
// Date's bottom edge: a trailing date sits ~1pt below the anchor; a wrapped
// date extends `statusHeight` below it. Leave ~6pt to the bubble's bottom
// edge, matching TextBubble's bottom inset.
let statusBottomEdge = statusAnchorY + max(1.0, statusSizeAndApply.0.height)
boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)
}
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: 0.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
self.containerNode.cornerRadius = layoutConstants.image.defaultCornerRadius
if let statusSizeAndApply {
// Match TextBubble: anchor the status node's x at the fixed text-block
// left edge (not the last line's minX, which is large for nested
// content and shoves the right-aligned date off the bubble). The status
// node positions the date trailing/below relative to this origin.
let statusFrame: CGRect
if let mediaStatusFrame {
// Overlaid pill: anchor to the media item's bottom-right corner,
// inset by the standard image status insets. page-coord (px,py)
// maps to self-coord (px, 1.0 + py).
let insets = layoutConstants.image.statusInsets
// Full-width flush media frames are widened by instantPageV2MediaEdgeBleed
// (4pt) past the visible/clipped right edge; clamp to the content width so
// the pill's trailing inset matches image messages (6pt, not 2pt).
let visibleMaxX = min(mediaStatusFrame.maxX, pageLayout?.contentSize.width ?? mediaStatusFrame.maxX)
let statusX = visibleMaxX - insets.right - statusSizeAndApply.0.width
let statusY = 1.0 + mediaStatusFrame.maxY - insets.bottom - statusSizeAndApply.0.height
statusFrame = CGRect(origin: CGPoint(x: statusX, y: statusY + streamingHeaderOffset), size: statusSizeAndApply.0)
} else {
let statusFrameY: CGFloat
if let lastTextLineFrame {
// Apply the text-rect pad (baseline → visible text bottom) for both
// the trailing and wrapped cases, so the date references the visible
// text bottom rather than the baseline. Mirrors the measure closure
// and TextBubble. Without it the wrapped date crowded the last line.
statusFrameY = 1.0 + lastTextLineFrame.maxY + lastTextLineTrailingPadding
} else if let pageLayout {
statusFrameY = 1.0 + pageLayout.contentSize.height
} else {
statusFrameY = 1.0
}
statusFrame = CGRect(origin: CGPoint(x: pageHorizontalInset, y: statusFrameY + streamingHeaderOffset), size: statusSizeAndApply.0)
}
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
if self.statusNode !== statusNode {
self.statusNode?.removeFromSupernode()
self.statusNode = statusNode
self.addSubnode(statusNode)
statusNode.reactionSelected = { [weak self] _, value, sourceView in
guard let self, let item = self.item else {
return
}
item.controllerInteraction.updateMessageReaction(item.topMessage, .reaction(value), false, sourceView)
}
statusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in
guard let self, let item = self.item else {
gesture?.cancel()
return
}
item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value)
}
statusNode.frame = statusFrame
} else {
animation.animator.updatePosition(layer: statusNode.layer, position: statusFrame.center, completion: nil)
animation.animator.updateBounds(layer: statusNode.layer, bounds: CGRect(origin: .zero, size: statusFrame.size), completion: nil)
}
} else if let statusNode = self.statusNode {
self.statusNode = nil
statusNode.removeFromSupernode()
}
if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported), let statusNode = self.statusNode {
statusNode.pressed = { [weak self] in
guard let self, let statusNode = self.statusNode, let item = self.item else {
return
}
item.controllerInteraction.displayImportedMessageTooltip(statusNode)
}
} else {
self.statusNode?.pressed = nil
}
if let pageLayout, let pageWebpage, let resolvedContent {
self.currentPageLayout = (
suggestedBoundingWidth,
ObjectIdentifier(item.presentationData.theme.theme),
item.presentationData.fontSize.baseDisplaySize,
self.currentExpandedDetails,
item.message.stableVersion,
(item.attributes.updatingMedia?.richText).map({ ObjectIdentifier($0) }),
resolvedContent.key,
showMoreExpanded,
pageLayout
)
let pageView = self.ensurePageView(item: item, webpage: pageWebpage, richPageKey: resolvedContent.key, showMoreExpanded: showMoreExpanded)
if self.checkboxesInteractive(item: item, resolved: resolvedContent) {
pageView.checkboxTapped = { [weak self] path, newValue in
guard let self, let item = self.item else {
return
}
item.controllerInteraction.toggleMessageRichTextCheckbox(item.message.id, path, newValue)
}
} else {
pageView.checkboxTapped = nil
}
pageView.update(layout: pageLayout, theme: pageTheme, animation: animation)
pageView.frame = CGRect(
origin: CGPoint(x: -1.0, y: streamingHeaderOffset),
size: pageLayout.contentSize
)
self.updatePageViewVisibilityRect()
if self.displayContentsUnderSpoilers {
pageView.setDisplayContentsUnderSpoilers(true, atLocation: nil, animated: false)
}
let showTextAsPlaceholder = item.associatedData.showTextAsPlaceholder
var isTranslating = resolvedContent.isTranslating
if showTextAsPlaceholder {
isTranslating = true
}
self.updateIsTranslating(isTranslating, showTextAsPlaceholder: showTextAsPlaceholder)
// Continue an in-flight anchor scroll that is waiting on a <details>
// expansion to re-lay-out. This runs on EVERY apply pass (not only the
// expand-triggered one), but only does anything while a scroll is pending
// — and scrollToAnchor is idempotent: each invocation either resolves and
// scrolls (clearing pending) or expands the next collapsed level, and the
// progress guard guarantees termination. So an unrelated relayout (theme,
// width, reactions) that lands mid-expand simply advances/no-ops the loop.
// Deferred via justDispatch to avoid re-entering layout from this apply.
if let pendingAnchor = self.pendingScrollAnchor {