eleven / messenger Public

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

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