eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// The plain-text chat bubble — the first shared SwiftUI leaf view (rendered by
4// the Mac debug pane now, destined for the iOS feed too; docs/native-chat.md
5// "share the leaf views, specialize the container"). Visual language mirrors
6// the web feed (style.css .msg .bubble): 18pt continuous corners with the
7// trailing bottom corner tightened to 6pt, 9×13 padding, muted 12pt sender
8// name above. Richer message types (reactions, replies, images, files) grow
9// alongside this one view at a time.
10private extension View {
11 // Conditional drag-out wrapper (the provider path names files correctly).
12 @ViewBuilder func draggableAttachment(_ item: AttachmentDragItem?) -> some View {
13 if let item {
14 onDrag { item.makeProvider() }
15 } else {
16 self
17 }
18 }
19}
20
21// The feed's reading size, in points — web parity: --msg-font-size in
22// style.css (15px desktop default, 17px touch). The Mac injects its Text Size
23// pref (View menu ⌘+/⌘−, Settings slider); iOS injects the Dynamic Type body
24// size so the system Text Size setting scales the feed. The default is the old
25// fixed 16, so a surface that doesn't inject renders exactly as before.
26private struct ChatFontSizeKey: EnvironmentKey {
27 static let defaultValue: CGFloat = 16
28}
29public extension EnvironmentValues {
30 var chatFontSize: CGFloat {
31 get { self[ChatFontSizeKey.self] }
32 set { self[ChatFontSizeKey.self] = newValue }
33 }
34}
35
36// Set by ReplyThreadView on its rows: inside the thread view every row already
37// IS the thread, so a per-row quote card or "N Replies" tally would only point
38// back at where the reader is standing — suppress both (the web's
39// `.chatapp-thread .reply-quote, .reply-count { display: none }`).
40private struct InReplyThreadKey: EnvironmentKey {
41 static let defaultValue = false
42}
43public extension EnvironmentValues {
44 var inReplyThread: Bool {
45 get { self[InReplyThreadKey.self] }
46 set { self[InReplyThreadKey.self] = newValue }
47 }
48}
49
50// The roster's @mention index (Mentions.swift) — injected by the feed
51// containers; nil (surfaces with no roster: Eleven conversations, previews)
52// renders text exactly as before. With it, plain runs grow bold mention
53// chips, tappable via MentionLink + the container's OpenURLAction.
54private struct MentionIndexKey: EnvironmentKey {
55 static let defaultValue: MentionIndex? = nil
56}
57public extension EnvironmentValues {
58 var mentionIndex: MentionIndex? {
59 get { self[MentionIndexKey.self] }
60 set { self[MentionIndexKey.self] = newValue }
61 }
62}
63
64// The action catalog a bubble offers — one definition feeding both the hover
65// strip and the right-click menu (the design doc's "consistent actions").
66public struct MessageActions {
67 public var canEdit: Bool
68 public var onReply: (() -> Void)?
69 /// "Reply in thread" (thread-pane spec §3.5): open the message's reply
70 /// thread — resolved to its ROOT, so replying to a reply joins the
71 /// existing thread — with the caret in the thread's composer, rather
72 /// than staging a reply on the channel bar. Same gating as onReply; nil
73 /// on surfaces where it's redundant (the docked pane's own bubbles).
74 public var onReplyInThread: (() -> Void)?
75 /// "Convert to thread": take the inline reply chain hanging under this
76 /// message out of the transcript and into a thread rooted on it. Set ONLY
77 /// where there is a chain to move (ChatFeedModel.inlineChainIDs) — never on
78 /// a message nobody answered, and never inside a thread view, where nothing
79 /// inline hangs off a bubble. Confirms before it fires: it reorganises the
80 /// chat for everyone and, in v1, can't be undone.
81 public var onConvertToThread: (() -> Void)?
82 public var onEdit: (() -> Void)?
83 public var onDelete: (() -> Void)?
84 public var onCopy: (() -> Void)?
85 public var onReact: ((String) -> Void)?
86 /// Messages' "Show Times": toggles the feed's right-hand time gutter.
87 public var timesShown = false
88 public var onToggleTimes: (() -> Void)?
89 /// Mute/unmute the sender for me (#578) — wired only on others' messages
90 /// that carry a uid (legacy uid-less messages have nothing to key on).
91 /// The label needs the resolved display name, so it rides here too.
92 public var senderMuted = false
93 public var senderLabel = ""
94 public var onMutePerson: (() -> Void)?
95 /// Report the sender to the space's admins (#578) — same gate as the
96 /// mute: others' uid-carrying messages only. Opens the report sheet.
97 public var onReport: (() -> Void)?
98
99 public init(canEdit: Bool = false,
100 onReply: (() -> Void)? = nil,
101 onReplyInThread: (() -> Void)? = nil,
102 onEdit: (() -> Void)? = nil,
103 onDelete: (() -> Void)? = nil, onCopy: (() -> Void)? = nil,
104 onReact: ((String) -> Void)? = nil,
105 timesShown: Bool = false, onToggleTimes: (() -> Void)? = nil,
106 senderMuted: Bool = false, senderLabel: String = "",
107 onMutePerson: (() -> Void)? = nil,
108 onReport: (() -> Void)? = nil) {
109 self.canEdit = canEdit
110 self.onReply = onReply
111 self.onReplyInThread = onReplyInThread
112 self.onEdit = onEdit
113 self.onDelete = onDelete
114 self.onCopy = onCopy
115 self.onReact = onReact
116 self.timesShown = timesShown
117 self.onToggleTimes = onToggleTimes
118 self.senderMuted = senderMuted
119 self.senderLabel = senderLabel
120 self.onMutePerson = onMutePerson
121 self.onReport = onReport
122 }
123
124 /// "Mute Alice" / "Unmute Alice" — one builder so the SwiftUI menu and the
125 /// Mac NSMenu can't drift ("them" when no name resolved, like the web).
126 public var mutePersonTitle: String {
127 let who = senderLabel.isEmpty ? "them" : senderLabel
128 return senderMuted ? "Unmute \(who)" : "Mute \(who)"
129 }
130}
131
132public struct MessageBubble: View {
133 public let message: ChatFeedModel.Message
134 /// Show the sender name above the bubble (group chats, first of a run).
135 public let showSender: Bool
136 /// The resolved sender name to show — the model's roster-by-uid lookup
137 /// (ChatFeedModel.senderName). Nil falls back to the payload's snapshot.
138 public let senderName: String?
139 public let theme: ChatTheme
140 /// Jump to another message (a quote's parent, or the first reply) — the
141 /// container owns scrolling, the leaf just asks.
142 public let onJump: ((Int64) -> Void)?
143 /// Open the reply-thread view on a root message id. When set, a quote-card
144 /// or "N Replies" tap lifts the whole thread (resolved to its ROOT) instead
145 /// of jumping the feed; onJump stays the belt for containers without a
146 /// thread view (matching the web's scrollToMessage fallback).
147 public let onOpenThread: ((Int64) -> Void)?
148 /// Resolve an attachment to its encrypted-blob location + key (the feed
149 /// model owns the thread key; the leaf just renders what it's given).
150 public let blobSource: ((Attachment) -> AvatarSource?)?
151 /// A loaded photo was clicked — the container presents the lightbox. The
152 /// CGRect is the thumbnail's frame in .global (window) coordinates, so the
153 /// lightbox can animate out from the image the way the web's does.
154 public let onImageTap: ((Attachment, PlatformImage, CGRect) -> Void)?
155 /// A file chip was clicked — the container downloads it (fetch + decrypt
156 /// is a deliberate act; the leaf never touches disk).
157 public let onFileTap: ((Attachment) -> Void)?
158 /// A video card was clicked; the CGRect is the card's window frame so the
159 /// video lightbox can zoom out of it like the photo one.
160 public let onVideoTap: ((Attachment, CGRect) -> Void)?
161 /// The attachment currently downloading, with 0…1 progress — its card
162 /// swaps the play badge for a radial ring (the web's showVideoRing).
163 public let downloading: (id: String, progress: Double)?
164 /// The message actions this bubble offers — the desktop hover strip and
165 /// the right-click menu render from the same catalog (reply / react /
166 /// copy / edit / delete), so the two entry points can't drift.
167 public let actions: MessageActions?
168 /// Makes a photo/video card draggable out to the Finder (decrypt-on-drop).
169 public let dragItem: ((Attachment) -> AttachmentDragItem?)?
170 /// Synchronous unfurl-cache peek: height pre-measurement (the iOS UIKit
171 /// feed measures rows before display) must see an already-resolved card.
172 public let peekUnfurl: ((URL) -> LinkPreview??)?
173 /// A YouTube card was clicked — the container lifts the player into the
174 /// lightbox (nil = the card plays in place, like the web).
175 public let onYouTube: ((String, Int, CGRect) -> Void)?
176 /// Unfurl a URL into a link-preview card (cached by the feed model).
177 public let unfurl: ((URL) async -> LinkPreview?)?
178
179 @Environment(\.colorScheme) private var scheme
180 @Environment(\.chatFontSize) private var fontSize
181 @Environment(\.inReplyThread) private var inReplyThread
182 @Environment(\.mentionIndex) private var mentionIndex
183
184 public init(message: ChatFeedModel.Message, showSender: Bool,
185 senderName: String? = nil,
186 theme: ChatTheme = .classic, onJump: ((Int64) -> Void)? = nil,
187 onOpenThread: ((Int64) -> Void)? = nil,
188 blobSource: ((Attachment) -> AvatarSource?)? = nil,
189 onImageTap: ((Attachment, PlatformImage, CGRect) -> Void)? = nil,
190 onFileTap: ((Attachment) -> Void)? = nil,
191 onVideoTap: ((Attachment, CGRect) -> Void)? = nil,
192 downloading: (id: String, progress: Double)? = nil,
193 actions: MessageActions? = nil,
194 dragItem: ((Attachment) -> AttachmentDragItem?)? = nil,
195 unfurl: ((URL) async -> LinkPreview?)? = nil,
196 peekUnfurl: ((URL) -> LinkPreview??)? = nil,
197 onYouTube: ((String, Int, CGRect) -> Void)? = nil) {
198 self.message = message
199 self.showSender = showSender
200 self.senderName = senderName
201 self.theme = theme
202 self.onJump = onJump
203 self.onOpenThread = onOpenThread
204 self.blobSource = blobSource
205 self.onImageTap = onImageTap
206 self.onFileTap = onFileTap
207 self.onVideoTap = onVideoTap
208 self.downloading = downloading
209 self.actions = actions
210 self.dragItem = dragItem
211 self.unfurl = unfurl
212 self.peekUnfurl = peekUnfurl
213 self.onYouTube = onYouTube
214 }
215
216
217 // The common case — plain text, no chrome around the bubble at all —
218 // renders WITHOUT the wrapper VStack and tag scaffolding. The profile
219 // pad's hot stacks were almost entirely StackLayout.sizeThatFits
220 // recursion (release SwiftUICore, so device Release pays it too), and
221 // every stack level costs on every cell recycle; most feed rows are
222 // exactly this shape.
223 private var isSimpleText: Bool {
224 message.attachments.isEmpty
225 && message.reply == nil
226 && message.reactions.isEmpty
227 && !message.edited
228 && message.replyCount == 0
229 && message.raw.warn != true
230 && message.raw.card == nil
231 && !(showSender && !message.mine)
232 && !message.text.isEmpty
233 && Self.cachedFirstLink(message.text) == nil
234 }
235
236 public var body: some View {
237 if isSimpleText {
238 bubble
239 .modifier(ActionsChrome(owner: self))
240 } else {
241 fullBody
242 .modifier(ActionsChrome(owner: self))
243 }
244 }
245
246 private var fullBody: some View {
247 VStack(alignment: message.mine ? .trailing : .leading, spacing: 3) {
248 if showSender, !message.mine, !(senderName ?? message.name).isEmpty {
249 Text(senderName ?? message.name)
250 .font(.system(size: 12))
251 .foregroundStyle(.secondary)
252 .padding(.horizontal, 6)
253 }
254 // An admin warning (#578) keeps the ordinary bubble but announces
255 // itself — a formal notice should never read like small talk
256 // (web: .warn-tag in buildMessageEl). Spoken as part of the bubble
257 // (the tag itself is hidden from VoiceOver; the bubble's label
258 // carries the notice) so the framing survives accessibility too.
259 if message.raw.warn == true {
260 Text("⚠️ A notice from an admin")
261 .font(.system(size: 12, weight: .bold))
262 .foregroundStyle(.red) // the native danger tone (web --danger)
263 .padding(.horizontal, 6)
264 .accessibilityHidden(true)
265 }
266 if !inReplyThread, let reply = message.reply, reply.name != nil || reply.snippet != nil {
267 replyQuote(reply)
268 }
269 ForEach(message.attachments, id: \.id) { att in
270 if att.isImage {
271 AttachmentImage(attachment: att, source: blobSource?(att), theme: theme,
272 onTap: onImageTap)
273 .draggableAttachment(dragItem?(att))
274 } else if att.isVideo {
275 VideoCard(attachment: att, mine: message.mine, theme: theme,
276 posterSource: att.poster.flatMap {
277 blobSource?(Attachment(id: $0, type: "image/jpeg"))
278 },
279 ringProgress: downloading?.id == att.id ? downloading?.progress : nil,
280 onTap: onVideoTap)
281 .draggableAttachment(dragItem?(att))
282 } else {
283 FileChip(attachment: att, mine: message.mine, theme: theme,
284 ringProgress: downloading?.id == att.id ? downloading?.progress : nil,
285 onTap: onFileTap)
286 }
287 }
288 // Web parity (chat.js): a message with a VALID card renders the
289 // card as its body — the text is that card's plain fallback and
290 // would be duplication beside it. An invalid/unknown card fails
291 // closed (Card.normalize → nil) and the text bubble shows instead.
292 if let card = Card.normalize(message.raw.card) {
293 CardView(card: card)
294 } else {
295 if !message.text.isEmpty {
296 if message.raw.warn == true {
297 // The warn tag above is a11y-hidden; the bubble speaks
298 // the notice so the framing is never lost to VoiceOver.
299 bubble.accessibilityLabel("A notice from an admin. \(message.text)")
300 } else {
301 bubble
302 }
303 }
304 // The web's precedence (chat.js): a YouTube link gets the
305 // click-to-play card; any other link gets the unfurl preview.
306 if let link = Self.cachedFirstLink(message.text) {
307 if let yt = youtubeId(link) {
308 YouTubeCard(id: yt.id, start: yt.start, onPlay: onYouTube)
309 } else if let unfurl {
310 LinkPreviewCard(link: link, theme: theme, unfurl: unfurl,
311 cached: peekUnfurl?(link) ?? nil)
312 }
313 }
314 }
315 if !message.reactions.isEmpty {
316 reactionChips
317 }
318 if message.edited {
319 Text("edited")
320 .font(.system(size: 11)) // .edited-tag
321 .foregroundStyle(.tertiary)
322 .padding(.horizontal, 6)
323 }
324 if !inReplyThread, message.replyCount > 0 {
325 repliesTag
326 }
327 }
328 // Everything action lives behind right-click, Messages-style — no
329 // hover chrome on native (the web keeps its hover menus) except a
330 // quiet timestamp beside the bubble. On the Mac the menu is a real
331 // NSMenu via a catcher that lets left events through, so drag-to-select
332 // still works on bubble text; elsewhere it's a SwiftUI context menu.
333 }
334
335 // The action chrome, attached ONLY when this surface offers actions:
336 // the iOS feed passes nil (its UIKit list owns the long-press menu),
337 // and installing the interaction anyway cost ~1ms on every cell
338 // recycle — pure scroll-path waste for a menu that could never show.
339 // The thread view and any other actions-bearing surface keep it. On
340 // the Mac it's the MessageRightClick overlay; on touch, the context
341 // menu whose preview shape hugs the bubble — without that shape UIKit
342 // snapshots the whole row stack (bubble + reactions + preview card)
343 // and flashes a rectangular background while the menu opens.
344 private struct ActionsChrome: ViewModifier {
345 let owner: MessageBubble
346 func body(content: Content) -> some View {
347 if let actions = owner.actions, !owner.message.action {
348 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
349 content.overlay {
350 MessageRightClick(id: owner.message.id, actions: actions,
351 mine: owner.message.mine,
352 timestamp: owner.hoverTime)
353 }
354 #else
355 content
356 .contentShape(.contextMenuPreview,
357 RoundedRectangle(cornerRadius: 18, style: .continuous))
358 .contextMenu { owner.menuItems(actions) }
359 #endif
360 } else {
361 content
362 }
363 }
364 }
365
366 // The Messages-inspired context menu: the reaction row up top (a palette
367 // ControlGroup renders the six-emoji allowlist as a Tapback-style row),
368 // then Reply / Copy / Edit / Delete, and the timestamp as a quiet footer.
369 @ViewBuilder private func menuItems(_ actions: MessageActions) -> some View {
370 if actions.onReact != nil {
371 ControlGroup {
372 ForEach(ChatFeedModel.allowedReactions, id: \.self) { emoji in
373 Button { actions.onReact?(emoji) } label: {
374 // Palette rows render IMAGE labels only — a text-emoji
375 // button comes out invisible, so rasterise the glyph.
376 Image(platform: Self.emojiImage(emoji))
377 }
378 }
379 }
380 .controlGroupStyle(.palette)
381 }
382 if let reply = actions.onReply {
383 Button("Reply…", action: reply)
384 }
385 // Right after Reply, as on the web — the label alone tells them apart.
386 if let inThread = actions.onReplyInThread {
387 Button("Reply in Thread…", action: inThread)
388 }
389 // Right after the thread verb: the two are about the same place, one
390 // for what comes next and one for what already happened. Present only
391 // when there's a chain under this message to move.
392 if let convert = actions.onConvertToThread {
393 Button("Convert to Thread…", action: convert)
394 }
395 if let copy = actions.onCopy {
396 Button("Copy", action: copy)
397 }
398 if actions.canEdit, let edit = actions.onEdit {
399 Button("Edit…", action: edit)
400 }
401 if let mute = actions.onMutePerson {
402 Button(actions.mutePersonTitle, action: mute)
403 }
404 if let report = actions.onReport {
405 Button("Report", action: report) // matches the web's plain label
406 }
407 if message.mine, let delete = actions.onDelete {
408 Divider() // the native idiom for a destructive action set apart
409 Button("Delete…", role: .destructive, action: delete)
410 }
411 }
412
413 // Rasterise an emoji for the palette row (image labels only), cached.
414 // Internal, not private: the Mac's NSMenu builds the same palette row out
415 // of real menu items (MessageRightClick) and needs the same glyphs.
416 private static var emojiImages: [String: PlatformImage] = [:]
417 static func emojiImage(_ emoji: String) -> PlatformImage {
418 if let hit = emojiImages[emoji] { return hit }
419 let side = 36.0 // @2x for a crisp ~18pt menu glyph
420 let attrs: [NSAttributedString.Key: Any] = [.font: PlatformFont.systemFont(ofSize: 28)]
421 let string = NSAttributedString(string: emoji, attributes: attrs)
422 // AppKit and UIKit disagree on boundingRect's signature (UIKit
423 // requires the context parameter).
424 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
425 let bounds = string.boundingRect(with: CGSize(width: side, height: side), options: [])
426 let image = NSImage(size: CGSize(width: side, height: side), flipped: false) { rect in
427 string.draw(at: CGPoint(x: (rect.width - bounds.width) / 2,
428 y: (rect.height - bounds.height) / 2))
429 return true
430 }
431 #else
432 let bounds = string.boundingRect(with: CGSize(width: side, height: side),
433 options: [], context: nil)
434 let image = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)).image { _ in
435 string.draw(at: CGPoint(x: (side - bounds.width) / 2, y: (side - bounds.height) / 2))
436 }
437 #endif
438 emojiImages[emoji] = image
439 return image
440 }
441
442 // The tappable quote of the parent, above the bubble (à la Messages) —
443 // style.css .reply-quote: bordered, 14pt corners, muted name + one-line
444 // ellipsized snippet; tapping jumps to the quoted message.
445 //
446 // Two verbs, two cards. A THREAD reply is only ever rendered inline
447 // because its root is paged out, so its card is the way back into its
448 // thread: when that thread holds more than this one reply the card wears
449 // the same link-coloured "N Replies" the root wears (web:
450 // .reply-quote-thread) — inside the card, so the whole thing reads as one
451 // way in rather than a quote with a badge stuck on.
452 private func replyQuote(_ reply: ReplyRef) -> some View {
453 let threadLink = ChatFeedModel.threadLinkCount(message)
454 return Button {
455 // A THREAD reply's card opens its thread on the root it points at.
456 // A PLAIN reply's card is a quote, and tapping a quote jumps to the
457 // message it quotes — the behaviour that predates threads, and the
458 // one a reader expects when nothing here said "thread". The jump is
459 // also the belt for a container with no thread view.
460 if let root = ChatFeedModel.threadRootID(of: message), let open = onOpenThread {
461 open(root)
462 } else if let id = reply.id, id > 0 {
463 onJump?(id)
464 }
465 } label: {
466 VStack(alignment: message.mine ? .trailing : .leading, spacing: 1) {
467 if let name = reply.name, !name.isEmpty {
468 Text(name).font(.system(size: 11)).foregroundStyle(.secondary)
469 }
470 Text(reply.snippet ?? "")
471 // two notches under the reading size at every size, like
472 // the web's .reply-quote-text (calc(var(--msg-font-size) - 2px))
473 .font(.system(size: fontSize - 2))
474 .foregroundStyle(.secondary)
475 .lineLimit(1)
476 .frame(maxWidth: 220, alignment: message.mine ? .trailing : .leading)
477 if threadLink > 0 {
478 Text(ChatFeedModel.replyCountLabel(threadLink))
479 .font(.system(size: 11, weight: .semibold)) // the tally's own type
480 .foregroundStyle(theme.accent.resolved(scheme))
481 .monospacedDigit()
482 .padding(.top, 1)
483 }
484 }
485 .padding(EdgeInsets(top: 6, leading: 11, bottom: 6, trailing: 11))
486 .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous)
487 .strokeBorder(.quaternary, lineWidth: 1))
488 .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
489 }
490 .buttonStyle(.plain)
491 // The card's own name says what tapping it does — "4 Replies" alone is
492 // a number read aloud. It keeps the visible label inside the spoken one
493 // (WCAG 2.5.3) and carries the quote it replaced.
494 .modifier(QuoteLabel(text: threadLink > 0
495 ? quoteThreadAccessibilityLabel(reply, count: threadLink) : nil))
496 }
497
498 // Applied only when there's something to say: an unconditional
499 // .accessibilityLabel would replace the plain card's default reading
500 // (its name + snippet) with whatever we handed it.
501 private struct QuoteLabel: ViewModifier {
502 let text: String?
503 func body(content: Content) -> some View {
504 if let text {
505 content.accessibilityLabel(Text(text))
506 } else {
507 content
508 }
509 }
510 }
511
512 // "Open thread, 4 Replies — quoting Al: see you at six".
513 private func quoteThreadAccessibilityLabel(_ reply: ReplyRef, count: Int) -> String {
514 let opener = "Open thread, \(ChatFeedModel.replyCountLabel(count))"
515 let quoted = [reply.name, reply.snippet].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ": ")
516 return quoted.isEmpty ? opener : "\(opener) — quoting \(quoted)"
517 }
518
519 // "N Replies" under a message that's been replied to — jumps to the first
520 // reply (style.css .reply-count, link-coloured).
521 private var repliesTag: some View {
522 Button {
523 // The tally only renders under a loaded root — this message IS the
524 // thread's root, so open the view on it (web buildReplyCount);
525 // jump-to-first-reply stays the no-thread-view belt.
526 if let open = onOpenThread {
527 open(message.id)
528 } else if let id = message.firstReplyID {
529 onJump?(id)
530 }
531 } label: {
532 Text(ChatFeedModel.replyCountLabel(message.replyCount))
533 .font(.system(size: 11, weight: .semibold))
534 .foregroundStyle(theme.accent.resolved(scheme))
535 .padding(.horizontal, 6)
536 .contentShape(Rectangle())
537 }
538 .buttonStyle(.plain)
539 }
540
541 // The per-emoji tallies under the bubble, tucked toward its tail — the
542 // count sits beside the emoji once it passes 1, and your own reaction is
543 // unmistakable: accent-tinted fill plus the accent ring.
544 private var reactionChips: some View {
545 HStack(spacing: 4) {
546 ForEach(message.reactions, id: \.emoji) { r in
547 reactionChip(r)
548 }
549 }
550 // iMessage seating: the cluster PERCHES on the bubble's tail corner,
551 // overlapping its bottom edge rather than hanging below it. The negative
552 // top padding lifts the row into the bubble (~8pt, landing inside its
553 // 9pt bottom padding so it kisses the corner without covering text) and
554 // reclaims the space so following tags tuck up under it; the VStack's
555 // trailing/leading alignment already hugs the tail. The grey pill (a
556 // shade darker than the bubble) carries its own contrast, so it seats
557 // cleanly over the corner without a cutout halo.
558 .padding(.top, -11) // -(≈8 overlap + the VStack's 3pt row spacing)
559 .offset(x: message.mine ? 2 : -2) // lean the cluster off the corner
560 .padding(.horizontal, 2)
561 }
562
563 // One reaction pill: a flat grey Tapback, a shade darker than the them-bubble
564 // so it holds up against the feed and any bubble it overlaps. Everyone else's
565 // is just grey and borderless; YOUR own is the only one that gets the accent
566 // ring — flush to the pill edge, no backdrop halo. The count appears only once
567 // a second person piles on (a lone "1" is noise — matches the web).
568 @ViewBuilder private func reactionChip(_ r: Reaction) -> some View {
569 let ink = theme.bubbleThemInk.resolved(scheme)
570 HStack(spacing: 3) {
571 Text(r.emoji).font(.system(size: 13))
572 if r.count > 1 {
573 Text("\(r.count)")
574 .font(.system(size: 11.5, weight: .semibold))
575 .foregroundStyle(ink.opacity(0.7))
576 }
577 }
578 .padding(.horizontal, 8)
579 .padding(.vertical, 3)
580 .background {
581 // Shape-only fill/ring/shadow (kept off the glyphs).
582 ZStack {
583 Capsule().fill(theme.bubbleThem.resolved(scheme))
584 Capsule().fill(ink.opacity(0.08)) // nudge a shade darker than the bubble
585 }
586 // Yours only — the accent ring sits flush at the pill edge (no halo).
587 .overlay {
588 if r.isMine { Capsule().strokeBorder(theme.accent.resolved(scheme), lineWidth: 1) }
589 }
590 .shadow(color: .black.opacity(scheme == .dark ? 0.22 : 0.06), radius: 1, y: 0.5)
591 }
592 }
593
594 @ViewBuilder private var bubble: some View {
595 // A message of just a few emoji renders large and bubble-less
596 // (style.css .jumbo-emoji), like the web.
597 if emojiOnlyCount(message.text) > 0 {
598 Text(message.text)
599 .font(.system(size: 44))
600 .padding(.vertical, 2)
601 .help(hoverTime)
602 } else {
603 // The allowlisted CommonMark subset (#531): MarkdownTree.swift is
604 // the twin of web/static/markdown.js, so a bubble's block/inline
605 // structure is identical in both feeds (vector-pinned).
606 MDBlockListView(
607 blocks: Self.cachedTree(message.text),
608 ctx: MDContext(fontSize: fontSize, ink: ink, mine: message.mine,
609 mentions: mentionIndex))
610 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
611 // Selectable AND right-clickable: the MessageRightClick catcher
612 // above only claims right/ctrl-clicks, so left-drag selection
613 // reaches the text system while our menu owns the context click.
614 // Mac-only: on touch the long-press menu owns Copy (native
615 // selection/callout is suppressed on bubbles, like the web), and
616 // the selection machinery cost real time on every cell recycle.
617 .textSelection(.enabled)
618 #endif
619 .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13))
620 .foregroundStyle(ink)
621 .background(shape.fill(message.mine
622 ? theme.bubbleMe.resolved(scheme)
623 : theme.bubbleThem.resolved(scheme)))
624 // Mac-assed timestamp: hover for the exact time instead of
625 // cluttering every row (the web shows time on demand too).
626 .help(hoverTime)
627 }
628 }
629
630 // Everything a nested markdown block needs from its bubble. Kept tiny so
631 // the recursive views below stay value types with cheap bodies.
632 struct MDContext {
633 let fontSize: CGFloat
634 let ink: Color
635 let mine: Bool
636 let mentions: MentionIndex?
637 }
638
639 // Blocks stack exactly as web's .bubble children do (6pt gaps). The two
640 // structs recurse into each other for quotes and list items.
641 struct MDBlockListView: View {
642 let blocks: [MDBlock]
643 let ctx: MDContext
644 var body: some View {
645 VStack(alignment: .leading, spacing: 6) {
646 ForEach(blocks.indices, id: \.self) { i in
647 MDBlockView(block: blocks[i], ctx: ctx)
648 }
649 }
650 }
651 }
652
653 struct MDBlockView: View {
654 let block: MDBlock
655 let ctx: MDContext
656 var body: some View {
657 switch block {
658 case .p(let children):
659 // A single unstyled run — most messages — skips the
660 // AttributedString build/replay entirely: plain Text lays
661 // out measurably cheaper, and this runs per cell recycle.
662 // A possible @mention (any "@" while a roster is injected)
663 // must take the rich path to grow its chip.
664 if children.count == 1, case .text(let s) = children[0],
665 !(ctx.mentions?.mayMention(s) ?? false) {
666 Text(s)
667 .font(.system(size: ctx.fontSize))
668 .lineSpacing(2)
669 } else {
670 Text(MessageBubble.inlineText(children, ctx: ctx))
671 // The reading size comes from \.chatFontSize (web
672 // parity: style.css --msg-font-size) — the Mac's pref
673 // or iOS's Dynamic Type body size; derived sizes keep
674 // the fixed ratios they've always had against it.
675 .font(.system(size: ctx.fontSize))
676 .lineSpacing(2)
677 .tint(ctx.ink) // links inherit the bubble's ink, underlined
678 }
679 case .codeBlock(let text, _):
680 // ``` fence — verbatim monospace on the chip background, long
681 // lines scrolling rather than wrapping (.msg .bubble pre).
682 ScrollView(.horizontal, showsIndicators: false) {
683 Text(text)
684 .font(.system(size: ctx.fontSize * (13.5 / 16), design: .monospaced))
685 }
686 .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10))
687 .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
688 .fill(ctx.ink.opacity(ctx.mine ? 0.22 : 0.08)))
689 case .quote(let children):
690 // "> " blockquote — quiet left rule, slightly dimmed content;
691 // quotes now hold whole blocks (lists, fences, nested quotes).
692 MDBlockListView(blocks: children, ctx: ctx)
693 .opacity(0.75)
694 .padding(.leading, 10)
695 .overlay(alignment: .leading) {
696 RoundedRectangle(cornerRadius: 1.5)
697 .fill(ctx.ink.opacity(0.35))
698 .frame(width: 3)
699 }
700 case .ul(let items):
701 MDListView(marker: { _ in "•" }, items: items, ctx: ctx)
702 case .ol(let start, let items):
703 MDListView(marker: { i in "\(start + i)." }, items: items, ctx: ctx)
704 }
705 }
706 }
707
708 // Tight lists, like the web: marker column + the item's blocks. Ordered
709 // markers render with monospaced digits so multi-digit lists align.
710 struct MDListView: View {
711 let marker: (Int) -> String
712 let items: [[MDBlock]]
713 let ctx: MDContext
714 var body: some View {
715 VStack(alignment: .leading, spacing: 2) {
716 ForEach(items.indices, id: \.self) { i in
717 HStack(alignment: .firstTextBaseline, spacing: 7) {
718 Text(marker(i))
719 .font(.system(size: ctx.fontSize))
720 .monospacedDigit()
721 .foregroundStyle(ctx.ink.opacity(0.75))
722 MDBlockListView(blocks: items[i], ctx: ctx)
723 }
724 }
725 }
726 .padding(.leading, 4)
727 }
728 }
729
730 private var ink: Color {
731 message.mine ? theme.bubbleMeInk.resolved(scheme) : theme.bubbleThemInk.resolved(scheme)
732 }
733
734 private var hoverTime: String {
735 if let hit = Self.hoverTimeMemo[message.at] { return hit }
736 if Self.hoverTimeMemo.count > 600 { Self.hoverTimeMemo.removeAll() }
737 let built = Date(timeIntervalSince1970: TimeInterval(message.at))
738 .formatted(date: .abbreviated, time: .shortened)
739 Self.hoverTimeMemo[message.at] = built
740 return built
741 }
742 // .formatted goes through ICU — cheap once, real money when every visible
743 // bubble re-evaluates its body on each model publish.
744 @MainActor private static var hoverTimeMemo: [Int64: String] = [:]
745
746 // Inline runs with the web's formatting applied — MarkdownTree.swift is
747 // the twin of web/static/markdown.js, so **bold**, _italics_, `code`,
748 // ~strikes~ and links render the same in both feeds, and marks now nest.
749 // Parse + build results memoised: SwiftUI re-evaluates every visible
750 // bubble's body on any model publish AND builds fresh rows as they
751 // materialise mid-scroll — re-parsing (cmark now, heavier than the old
752 // regex pass) + rebuilding the AttributedString each time was a per-frame
753 // cost that read as periodic scroll hitching.
754 private struct InlineKey: Hashable {
755 let size: CGFloat
756 let mine: Bool
757 // Mention chips bake the viewer's roster into the runs, so the
758 // index's rev keys the memo — a rename or late label decrypt must
759 // rebuild, not replay (the web's repaintMentionsOf, done here).
760 let mentionRev: Int
761 let children: [MDInline]
762 }
763 // Two-generation memos (Memo.swift) — the old `removeAll()`-at-600 caps
764 // wiped every cache whenever one filled, so a long session re-parsed
765 // whole feeds on thread open (the 1–3s opens of #1210).
766 @MainActor private static var richMemo = Memo<InlineKey, AttributedString>()
767 @MainActor private static var treeMemo = Memo<String, [MDBlock]>()
768
769 @MainActor static func cachedTree(_ text: String) -> [MDBlock] {
770 treeMemo.get(text) { markdownTree(text) }
771 }
772
773 // firstLink re-runs the block + URL regex scan from scratch — memoised for
774 // the same reason as the parses above: it's called from every non-card
775 // bubble's body, on every publish and every row materialisation.
776 @MainActor private static var linkMemo = Memo<String, URL?>()
777
778 @MainActor static func cachedFirstLink(_ text: String) -> URL? {
779 linkMemo.get(text) { firstLink(text) }
780 }
781
782 @MainActor static func inlineText(_ children: [MDInline], ctx: MDContext) -> AttributedString {
783 // The built runs bake in \.chatFontSize, so it's part of the key — a
784 // ⌘+ / Text Size change must rebuild, not replay the old size.
785 let key = InlineKey(size: ctx.fontSize, mine: ctx.mine,
786 mentionRev: ctx.mentions?.rev ?? 0, children: children)
787 return richMemo.get(key) { buildInline(children, ctx: ctx) }
788 }
789
790 // Recursive, so nesting composes the way the web's element tree does:
791 // bold inside italic, marks inside link labels, strike over either.
792 private static func buildInline(
793 _ children: [MDInline], ctx: MDContext,
794 bold: Bool = false, italic: Bool = false, strike: Bool = false
795 ) -> AttributedString {
796 func styled(_ s: String) -> AttributedString {
797 var a = AttributedString(s)
798 if bold || italic {
799 var f: Font = .system(size: ctx.fontSize, weight: bold ? .bold : .regular)
800 if italic { f = f.italic() }
801 a.font = f
802 } // plain runs carry no font, inheriting the paragraph Text's
803 if strike { a.strikethroughStyle = .single }
804 return a
805 }
806 // An @mention chip: the web's .mention — semibold, bubble ink, no
807 // underline. Tappable ones carry the MentionLink URL for the feed's
808 // OpenURLAction; a self-mention stays plain bold (web mentionable()).
809 func mentionRun(_ e: MentionIndex.Entry) -> AttributedString {
810 var a = AttributedString("@" + e.display)
811 var f: Font = .system(size: ctx.fontSize, weight: bold ? .bold : .semibold)
812 if italic { f = f.italic() }
813 a.font = f
814 if strike { a.strikethroughStyle = .single }
815 if e.tappable, let url = MentionLink.url(uid: e.uid) {
816 a.link = url
817 a.foregroundColor = ctx.ink // never the tint blue links get
818 }
819 return a
820 }
821 var out = AttributedString()
822 for node in children {
823 switch node {
824 case .text(let s):
825 // Mentions are scanned only in plain runs — never inside
826 // code or link labels — matching the web's plain hook.
827 if let idx = ctx.mentions, idx.mayMention(s) {
828 for seg in idx.split(s) {
829 switch seg {
830 case .text(let t): out += styled(t)
831 case .mention(let e): out += mentionRun(e)
832 }
833 }
834 } else {
835 out += styled(s)
836 }
837 case .br:
838 out += AttributedString("\n")
839 case .code(let s):
840 // Narrow no-break spaces give the chip its side padding — an
841 // AttributedString background can't pad a run (web: 1px 5px).
842 var a = AttributedString("\u{202F}\(s)\u{202F}")
843 a.font = .system(size: ctx.fontSize * 0.92, design: .monospaced) // 0.92em (style.css)
844 a.backgroundColor = ctx.ink.opacity(ctx.mine ? 0.22 : 0.08)
845 if strike { a.strikethroughStyle = .single }
846 out += a
847 case .link(let href, let linkChildren):
848 // No mention scan inside a link's label (web parity: the
849 // plain hook never runs inside <a>) — and a chip's own link
850 // must never fight the label's real href.
851 let linkCtx = MDContext(fontSize: ctx.fontSize, ink: ctx.ink,
852 mine: ctx.mine, mentions: nil)
853 var a = buildInline(linkChildren, ctx: linkCtx, bold: bold, italic: italic, strike: strike)
854 if let url = URL(string: href) { a.link = url }
855 a.underlineStyle = .single
856 a.foregroundColor = ctx.ink
857 out += a
858 case .strong(let inner):
859 out += buildInline(inner, ctx: ctx, bold: true, italic: italic, strike: strike)
860 case .em(let inner):
861 out += buildInline(inner, ctx: ctx, bold: bold, italic: true, strike: strike)
862 case .strike(let inner):
863 out += buildInline(inner, ctx: ctx, bold: bold, italic: italic, strike: true)
864 }
865 }
866 return out
867 }
868
869 // The unfurled OpenGraph card under a bubble with a link — style.css
870 // .lp-card: 280 max, image capped at 150 and cover-cropped, title/desc/host
871 // on the them-bubble background. Clicking opens the link in the browser.
872 private struct LinkPreviewCard: View {
873 let link: URL
874 let theme: ChatTheme
875 let unfurl: (URL) async -> LinkPreview?
876
877 @Environment(\.colorScheme) private var scheme
878 @Environment(\.openURL) private var openURL
879 @State private var preview: LinkPreview?
880
881 init(link: URL, theme: ChatTheme, unfurl: @escaping (URL) async -> LinkPreview?,
882 cached: LinkPreview?? = nil) {
883 self.link = link
884 self.theme = theme
885 self.unfurl = unfurl
886 // A resolved cache entry renders SYNCHRONOUSLY — off-screen height
887 // measurement never runs .task, so without this the card would be
888 // invisible to the measuring pass.
889 _preview = State(initialValue: cached ?? nil)
890 }
891
892 var body: some View {
893 // The task must hang off a view that EXISTS pre-fetch — lifecycle
894 // modifiers never fire on an empty view, so a bare `if let` here
895 // silently never unfurled anything. The 1×1 clear placeholder is
896 // the fetch's anchor.
897 Group {
898 if let preview {
899 card(preview)
900 } else {
901 Color.clear.frame(width: 1, height: 1)
902 }
903 }
904 // Identity-reused on recycle (see AttachmentImage): another
905 // link's resolved card must not render under this URL while the
906 // fresh unfurl runs.
907 .onChange(of: link) { _, _ in preview = nil }
908 // A cancelled task (the row went away, or recycled onto another
909 // link) must never write its answer back: the model keeps the
910 // fetch alive past the cancellation, so the row that replaces this
911 // one gets the card — clobbering `preview` here would blank it.
912 .task(id: link) {
913 let resolved = await unfurl(link)
914 guard !Task.isCancelled else { return }
915 preview = resolved
916 }
917 }
918
919 private func card(_ preview: LinkPreview) -> some View {
920 Button {
921 openURL(preview.url)
922 } label: {
923 VStack(alignment: .leading, spacing: 0) {
924 if let imageURL = preview.imageURL {
925 AsyncImage(url: imageURL) { image in
926 image.resizable().scaledToFill()
927 } placeholder: {
928 Color.clear
929 }
930 // A FIXED slot, not max-caps: the card's
931 // height must be identical before and after
932 // the image loads — the measure pass never
933 // loads images, and a load-dependent height
934 // is a cell whose card paints over the rows
935 // below it (the 2026-07-21 App-test-group
936 // separator overlap: cell 227 vs ideal 366,
937 // the delta exactly this slot).
938 .frame(width: 280, height: 150)
939 .clipped()
940 }
941 VStack(alignment: .leading, spacing: 2) {
942 Text(preview.title)
943 .font(.system(size: 14, weight: .semibold))
944 .lineLimit(2)
945 if !preview.description.isEmpty {
946 Text(preview.description)
947 .font(.system(size: 12))
948 .foregroundStyle(.secondary)
949 .lineLimit(2)
950 }
951 Text(preview.url.host() ?? "")
952 .font(.system(size: 11))
953 .foregroundStyle(.secondary)
954 .padding(.top, 3)
955 }
956 .padding(EdgeInsets(top: 8, leading: 11, bottom: 8, trailing: 11))
957 }
958 .frame(maxWidth: 280, alignment: .leading)
959 .background(theme.bubbleThem.resolved(scheme))
960 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
961 .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous)
962 .strokeBorder(.quaternary, lineWidth: 1))
963 .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
964 }
965 .buttonStyle(.plain)
966 }
967 }
968
969 // A download chip for a non-image, non-video attachment — style.css
970 // .file-chip: emoji icon, name + size, bubble colours (me-tinted on own
971 // messages). Tapping downloads (fetch + decrypt happens in the container);
972 // while that runs the icon becomes a progress ring, mirroring the video
973 // card's showVideoRing.
974 private struct FileChip: View {
975 let attachment: Attachment
976 let mine: Bool
977 let theme: ChatTheme
978 let ringProgress: Double? // non-nil while this file downloads
979 let onTap: ((Attachment) -> Void)?
980
981 @Environment(\.colorScheme) private var scheme
982
983 var body: some View {
984 Button { onTap?(attachment) } label: {
985 HStack(spacing: 6) {
986 if let ringProgress {
987 ZStack {
988 Circle().stroke(.primary.opacity(0.2), lineWidth: 2)
989 Circle().trim(from: 0, to: max(0.05, ringProgress))
990 .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round))
991 .rotationEffect(.degrees(-90))
992 }
993 .frame(width: 22, height: 22)
994 } else {
995 Text(fileIcon(attachment.name)).font(.system(size: 22))
996 }
997 VStack(alignment: .leading, spacing: 0) {
998 Text(attachment.name ?? "file")
999 .font(.system(size: 14))
1000 .lineLimit(1)
1001 .truncationMode(.middle)
1002 if let size = attachment.size {
1003 Text(formatBytes(size))
1004 .font(.system(size: 12))
1005 .opacity(0.7)
1006 }
1007 }
1008 }
1009 .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14))
1010 .frame(maxWidth: 260, alignment: .leading)
1011 .fixedSize(horizontal: true, vertical: false)
1012 .foregroundStyle(mine ? theme.bubbleMeInk.resolved(scheme)
1013 : theme.bubbleThemInk.resolved(scheme))
1014 .background(RoundedRectangle(cornerRadius: 18, style: .continuous)
1015 .fill(mine ? theme.bubbleMe.resolved(scheme)
1016 : theme.bubbleThem.resolved(scheme)))
1017 .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1018 }
1019 .buttonStyle(.plain)
1020 .help(attachment.name ?? "file")
1021 }
1022 }
1023
1024 // A video attachment: the poster frame (sized like an image) with a play
1025 // badge and duration chip — chat.js buildVideoCard's resting state. Tapping
1026 // hands the attachment to the container (download for now; in-place
1027 // playback without writing plaintext to disk is a follow-up).
1028 private struct VideoCard: View {
1029 let attachment: Attachment
1030 let mine: Bool
1031 let theme: ChatTheme
1032 let posterSource: AvatarSource?
1033 let ringProgress: Double? // non-nil while this clip downloads
1034 let onTap: ((Attachment, CGRect) -> Void)?
1035
1036 @Environment(\.colorScheme) private var scheme
1037 @State private var poster: PlatformImage?
1038 // The card's live window frame — the video lightbox zooms out from here.
1039 // A non-observed holder, not @State: reactively storing it looped layout
1040 // (see LiveFrame / #580). Read at tap time only.
1041 @State private var liveFrame = LiveFrame()
1042
1043 var body: some View {
1044 let box = displaySize()
1045 ZStack {
1046 RoundedRectangle(cornerRadius: 18, style: .continuous)
1047 .fill(theme.bubbleThem.resolved(scheme))
1048 if let poster {
1049 Image(platform: poster)
1050 .resizable()
1051 .scaledToFill()
1052 .frame(width: box.width, height: box.height)
1053 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1054 }
1055 // While the clip downloads, a radial progress ring wraps the
1056 // play badge (the web's showVideoRing).
1057 Image(systemName: "play.fill")
1058 .font(.system(size: 18))
1059 .foregroundStyle(.white)
1060 .padding(14)
1061 .background(Circle().fill(.black.opacity(0.55)))
1062 .overlay {
1063 if let p = ringProgress {
1064 // Flush with the badge's black disc: the stroke is
1065 // centred on its path, so inset by half the line
1066 // width and the ring's outer edge kisses the rim.
1067 ZStack {
1068 Circle().stroke(.white.opacity(0.3), lineWidth: 3)
1069 Circle()
1070 .trim(from: 0, to: p)
1071 .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
1072 .rotationEffect(.degrees(-90))
1073 .animation(.linear(duration: 0.1), value: p)
1074 }
1075 .padding(1.5)
1076 }
1077 }
1078 if let dur = attachment.dur {
1079 Text(formatDuration(dur))
1080 .font(.system(size: 11, weight: .semibold))
1081 .foregroundStyle(.white)
1082 .padding(.horizontal, 6)
1083 .padding(.vertical, 2)
1084 .background(Capsule().fill(.black.opacity(0.55)))
1085 .frame(maxWidth: .infinity, maxHeight: .infinity,
1086 alignment: .bottomTrailing)
1087 .padding(8)
1088 }
1089 }
1090 .frame(width: box.width, height: box.height)
1091 .trackingFrame(into: liveFrame)
1092 .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1093 .onTapGesture { onTap?(attachment, liveFrame.rect) }
1094 // Identity-reused on recycle (see AttachmentImage): the previous
1095 // clip's poster must not survive into another message's card.
1096 .onChange(of: attachment.id) { _, _ in poster = nil }
1097 .task(id: attachment.id) {
1098 guard poster == nil, let posterSource else { return }
1099 poster = await BlobImageLoader.shared.image(posterSource,
1100 maxPixel: feedImageMaxPixel)
1101 }
1102 .help(attachment.name ?? "video")
1103 }
1104
1105 private func displaySize() -> CGSize {
1106 guard let w = attachment.w, let h = attachment.h, w > 0, h > 0 else {
1107 return CGSize(width: 260, height: 160)
1108 }
1109 let scale = min(260.0 / Double(w), 320.0 / Double(h), 1)
1110 return CGSize(width: Double(w) * scale, height: Double(h) * scale)
1111 }
1112 }
1113
1114 // One image attachment, sized exactly like the web (.msg-image + chat.js):
1115 // the payload's w/h reserve the final box BEFORE the blob decrypts, so the
1116 // feed doesn't jump as photos pop in; capped at 260×320, 18pt corners,
1117 // them-bubble placeholder while loading.
1118 private struct AttachmentImage: View {
1119 let attachment: Attachment
1120 let source: AvatarSource?
1121 let theme: ChatTheme
1122 let onTap: ((Attachment, PlatformImage, CGRect) -> Void)?
1123
1124 @Environment(\.colorScheme) private var scheme
1125 @State private var image: PlatformImage?
1126 @State private var blur: PlatformImage?
1127 // The thumbnail's live window frame — the lightbox zooms out from here.
1128 // A non-observed holder, not @State: reactively storing it looped layout
1129 // (see LiveFrame / #580). Read at tap time only.
1130 @State private var liveFrame = LiveFrame()
1131 // Animated GIFs only: reduced-motion users get them paused at first.
1132 @State private var playing = !gifsStartPaused()
1133
1134 var body: some View {
1135 let box = displaySize()
1136 ZStack {
1137 // Under everything: the BlurHash ghost of the photo when the
1138 // payload carries one (native sends do; web sends fall back to
1139 // the flat bubble fill until the web adopts bh).
1140 if let blur {
1141 Image(platform: blur)
1142 .resizable()
1143 .interpolation(.medium)
1144 .frame(width: box.width, height: box.height)
1145 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1146 } else {
1147 RoundedRectangle(cornerRadius: 18, style: .continuous)
1148 .fill(theme.bubbleThem.resolved(scheme))
1149 }
1150 if let image {
1151 loadedImage(image, box: box)
1152 // A freshly decrypted photo fades over the placeholder
1153 // (the flash read as a glitch); the fade only animates
1154 // when the load path wraps the assignment in
1155 // withAnimation — cache hits (every scroll-back
1156 // rematerialisation) insert without animation and pop
1157 // instantly, so history never shimmers.
1158 .transition(.opacity)
1159 }
1160 }
1161 .frame(width: box.width, height: box.height)
1162 .trackingFrame(into: liveFrame)
1163 .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1164 .onTapGesture {
1165 guard let image else { return }
1166 // An animated GIF claims the click for play/pause (as on the
1167 // web); everything else keeps the zoom-in lightbox click.
1168 if isAnimatedGIF(image) { playing.toggle() }
1169 else { onTap?(attachment, image, liveFrame.rect) }
1170 }
1171 // Feed cells recycle by REUSING this view's identity for a
1172 // different message (no per-message .id — the remount was the
1173 // scroll cost), so loaded state must reset when the attachment
1174 // changes or the previous photo would pass the task's
1175 // image == nil guard and stick, wrongly, forever.
1176 .onChange(of: attachment.id) { _, _ in
1177 image = nil
1178 blur = nil
1179 playing = !gifsStartPaused()
1180 }
1181 .task(id: attachment.id) {
1182 guard image == nil, let source else { return }
1183 // The feed decodes downsampled to its box class; a Mac
1184 // animated GIF keeps the full multi-frame NSImage so it plays.
1185 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
1186 let px: Int? = attachment.isGIF ? nil : feedImageMaxPixel
1187 #else
1188 let px: Int? = feedImageMaxPixel
1189 #endif
1190 if let hit = BlobImageLoader.shared.cached(source.blobID, maxPixel: px) {
1191 image = hit // instant — no fade for cached rematerialisation
1192 return
1193 }
1194 // Decode the BlurHash ghost first (sub-millisecond at 32px) so
1195 // the wait shows the photo's shape, not a grey slab.
1196 if let bh = attachment.bh, blur == nil,
1197 let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value {
1198 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
1199 blur = PlatformImage(cgImage: cg, size: .zero)
1200 #else
1201 blur = PlatformImage(cgImage: cg)
1202 #endif
1203 }
1204 guard let loaded = await BlobImageLoader.shared.image(source, maxPixel: px) else { return }
1205 withAnimation(.easeOut(duration: 0.18)) { image = loaded }
1206 }
1207 .help(attachment.name ?? "photo")
1208 }
1209
1210 @ViewBuilder private func loadedImage(_ image: PlatformImage, box: CGSize) -> some View {
1211 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
1212 if isAnimatedGIF(image) {
1213 AnimatedGIFView(image: image, playing: playing)
1214 .frame(width: box.width, height: box.height)
1215 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1216 } else {
1217 still(image, box: box)
1218 }
1219 #else
1220 still(image, box: box)
1221 #endif
1222 }
1223
1224 private func still(_ image: PlatformImage, box: CGSize) -> some View {
1225 Image(platform: image)
1226 .resizable()
1227 .scaledToFill()
1228 .frame(width: box.width, height: box.height)
1229 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
1230 }
1231
1232 // A GIF that actually has frames to play — a single-frame "GIF" is
1233 // just a photo and keeps photo behaviour (SwiftUI still + lightbox).
1234 private func isAnimatedGIF(_ image: PlatformImage) -> Bool {
1235 #if canImport(AppKit) && !targetEnvironment(macCatalyst)
1236 return attachment.isGIF && image.gifFrameCount > 1
1237 #else
1238 return false // iOS native beta renders GIFs as stills for now
1239 #endif
1240 }
1241
1242 // chat.js: scale = min(260/w, 320/h, 1). Unknown dimensions (very old
1243 // payloads) get a modest square placeholder rather than a layout jump.
1244 private func displaySize() -> CGSize {
1245 guard let w = attachment.w, let h = attachment.h, w > 0, h > 0 else {
1246 return CGSize(width: 200, height: 200)
1247 }
1248 let scale = min(260.0 / Double(w), 320.0 / Double(h), 1)
1249 return CGSize(width: Double(w) * scale, height: Double(h) * scale)
1250 }
1251 }
1252
1253 // The web bubble's signature shape: fully rounded except the corner that
1254 // points at the sender (me → bottom-right, them → bottom-left).
1255 private var shape: UnevenRoundedRectangle {
1256 let r: CGFloat = 18, tail: CGFloat = 6
1257 return UnevenRoundedRectangle(
1258 cornerRadii: .init(
1259 topLeading: r,
1260 bottomLeading: message.mine ? r : tail,
1261 bottomTrailing: message.mine ? tail : r,
1262 topTrailing: r
1263 ),
1264 style: .continuous
1265 )
1266 }
1267}
1268
1269// The quiet stand-in for a muted member's message (#578) — visible and
1270// tappable rather than silently gone, because the honest framing of a mute is
1271// "you stop seeing them", not "they stop existing". Tapping reveals that one
1272// message for the session (the container tracks reveals; a mute means muted
1273// across launches). Shared by the iOS and Mac feeds and their thread views —
1274// the web's .muted-stub, mirrored. Always a "them" row: own messages never fold.
1275public struct MutedMessageStub: View {
1276 let onShow: () -> Void
1277
1278 public init(onShow: @escaping () -> Void) {
1279 self.onShow = onShow
1280 }
1281
1282 public var body: some View {
1283 Button(action: onShow) {
1284 HStack(spacing: 8) {
1285 Text("Muted message")
1286 .italic()
1287 .foregroundStyle(.secondary)
1288 Text("Show")
1289 .fontWeight(.semibold)
1290 .foregroundStyle(.tint)
1291 }
1292 // Relative sizes so the pill tracks Dynamic Type / the Mac's
1293 // Text Size pref like every other feed line.
1294 .font(.footnote)
1295 .padding(.horizontal, 12)
1296 .padding(.vertical, 6)
1297 .background(Capsule().strokeBorder(.quaternary, lineWidth: 1))
1298 .contentShape(Capsule())
1299 }
1300 .buttonStyle(.plain)
1301 // One element, one act: the whole pill is the button, spoken as such.
1302 .accessibilityLabel("Muted message, show")
1303 }
1304}
1305