| 1 | import 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. |
| 10 | private 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. |
| 26 | private struct ChatFontSizeKey: EnvironmentKey { |
| 27 | static let defaultValue: CGFloat = 16 |
| 28 | } |
| 29 | public 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 }`). |
| 40 | private struct InReplyThreadKey: EnvironmentKey { |
| 41 | static let defaultValue = false |
| 42 | } |
| 43 | public 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. |
| 54 | private struct MentionIndexKey: EnvironmentKey { |
| 55 | static let defaultValue: MentionIndex? = nil |
| 56 | } |
| 57 | public 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"). |
| 66 | public 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 | |
| 132 | public 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. |
| 1275 | public 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 | |