| 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 | 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 | |
| 116 | public 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. |
| 1199 | public 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 | |