| 1 | import SwiftUI |
| 2 | |
| 3 | // Animated-GIF playback for the native Mac feed. BlobImageLoader decodes an |
| 4 | // attachment's decrypted bytes with NSImage(data:), which keeps every GIF |
| 5 | // frame in its bitmap rep — so the cached image animates as-is inside an |
| 6 | // NSImageView (`animates = true`), no second decode and no second cache. |
| 7 | // SwiftUI's Image would flatten it to the first frame, which is exactly what |
| 8 | // the paused state shows (matching the web's canvas-drawn still). |
| 9 | |
| 10 | /// Whether the system asks for reduced motion — animated GIFs then start |
| 11 | /// paused (a click still plays them). The web twin is chat.js honouring |
| 12 | /// prefers-reduced-motion in wireGifToggle. |
| 13 | func gifsStartPaused() -> Bool { |
| 14 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 15 | return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion |
| 16 | #else |
| 17 | // iOS renders GIFs as stills in the native beta feed (the default feed is |
| 18 | // the web page, which handles reduced motion itself), so nothing to gate. |
| 19 | return false |
| 20 | #endif |
| 21 | } |
| 22 | |
| 23 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 24 | import AppKit |
| 25 | |
| 26 | extension NSImage { |
| 27 | /// Frames in the image's bitmap rep — >1 means it will actually animate. |
| 28 | /// A still that merely claims image/gif (single-frame GIF) reports 1 and |
| 29 | /// keeps the ordinary photo behaviour (lightbox on click). |
| 30 | var gifFrameCount: Int { |
| 31 | guard let rep = representations.first as? NSBitmapImageRep, |
| 32 | let n = rep.value(forProperty: .frameCount) as? Int else { return 1 } |
| 33 | return n |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /// NSImageView host for an animated GIF; `playing` pauses/resumes (paused |
| 38 | /// shows the first frame). Events pass straight through the AppKit view, so |
| 39 | /// the SwiftUI container keeps the tap toggle, the right-click menu, and |
| 40 | /// drag-out exactly as for still photos. |
| 41 | struct AnimatedGIFView: NSViewRepresentable { |
| 42 | let image: NSImage |
| 43 | let playing: Bool |
| 44 | |
| 45 | func makeNSView(context: Context) -> ClickThroughImageView { |
| 46 | let v = ClickThroughImageView() |
| 47 | v.imageScaling = .scaleProportionallyUpOrDown |
| 48 | v.image = image |
| 49 | v.animates = playing |
| 50 | // The SwiftUI .frame decides the size — the GIF's intrinsic size must |
| 51 | // not push the bubble wider than the reserved w/h box. |
| 52 | v.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| 53 | v.setContentCompressionResistancePriority(.defaultLow, for: .vertical) |
| 54 | return v |
| 55 | } |
| 56 | |
| 57 | func updateNSView(_ v: ClickThroughImageView, context: Context) { |
| 58 | if v.image !== image { v.image = image } |
| 59 | v.animates = playing |
| 60 | } |
| 61 | |
| 62 | // NSImageView is an NSControl, so left it would swallow the clicks the |
| 63 | // bubble needs; returning nil routes every event to the SwiftUI layer. |
| 64 | final class ClickThroughImageView: NSImageView { |
| 65 | override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| 66 | } |
| 67 | } |
| 68 | #endif |
| 69 | |