Idiomatic drop-to-attach on Mac + iOS (fixes CleanShot drags) (#563)
* mac+ios: idiomatic drop-to-attach — any drag source, no overlay The Mac feed accepted only fileURL drags, so app-vended drags (CleanShot, Photos: raw data / file promises, no URL) bounced. One provider pipeline now serves paste and drop: real files stage directly, everything else image/movie-shaped materialises via loadFileRepresentation (which fulfils promises too). The 'Drop to attach' overlay is gone — the system's own + drag badge is the affordance (Paul's call). A quiet info-level tap logs each drop's provider types, so the next source-app quirk diagnoses from Console without an instrumented build. iOS NativeChatScreen gains drop support (it had none): images, movies and files from split-view/app-to-app drags stage like the pickers' choices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: the send pin must HOLD, not just pass once An image send verified flush, then the staged strip cleared, the composer inset shrank, and the content shifted under the satisfied pin — the image landed below the fold. The flush verify now re-checks 200ms later and resumes pinning if the layout moved underneath it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * core: freshly loaded photos fade in (180ms); cache hits stay instant The decrypt-and-pop read as a flash. Only the download path wraps the assignment in withAnimation, so scroll-back rematerialisation (cache hits) never shimmers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web+mac+ios: BlurHash placeholders for photo loads The grey placeholder becomes the photo's own blurry ghost. The hash is derived from image content, so it rides INSIDE the encrypted payload (attachments[].bh, beside w/h) — never as server metadata. Codec on all three surfaces: web/static/blurhash.js + LChatCore Blurhash.swift, both ports of the reference (woltapp/blurhash, MIT — its Swift files are UIImage-only and copy-in by design, and LChatCore stays dependency-free), pinned to each other by a shared cross-implementation vector: both suites encode the same 8x6 pixel grid and must produce the same hash. Encode on upload (a ~32px thumbnail — DCT is O(pixels)); decode to the reserved box's background on web, and under the 180ms fade natively. Messages without bh keep the flat bubble fill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+725
−56
ios/LChat/Views/NativeChatScreen.swift+36 −0ios/LChatCore/Sources/LChatCore/Blurhash.swift+184 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+13 −1ios/LChatCore/Sources/LChatCore/MessageBubble.swift+49 −15ios/LChatCore/Sources/LChatCore/Models.swift+3 −0ios/LChatCore/Tests/LChatCoreTests/BlurhashTests.swift+119 −0ios/LChatMac/NativeFeedView.swift+56 −36package.json+1 −1test/blurhash.test.mjs+86 −0web/static/apps/chat.js+19 −0web/static/blurhash.js+137 −0web/static/shell.js+22 −3
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index 80a838b..49a9c5a 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -1057,6 +1057,42 @@ struct NativeChatScreen: View { |
| .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.item]) { result in |
| if case .success(let url) = result { stageFile(url) } |
| } |
| + // Drop-to-attach (iPad split view, iPhone app-to-app drags): images, |
| + // movies and files stage exactly like the pickers' choices. The system |
| + // drag badge is the whole affordance — no overlay (matching the Mac). |
| + .onDrop(of: defaultCommand == nil ? [.image, .movie, .fileURL] : [], |
| + isTargeted: nil) { providers in |
| + stageDroppedProviders(providers) |
| + return true |
| + } |
| + } |
| + |
| + // One provider pipeline for drops: prefer a real file (name + full |
| + // quality), else raw image/movie data with the provider's suggested name. |
| + private func stageDroppedProviders(_ providers: [NSItemProvider]) { |
| + for provider in providers { |
| + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| + _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| + if let url { DispatchQueue.main.async { stageFile(url) } } |
| + } |
| + continue |
| + } |
| + if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { |
| + provider.loadDataRepresentation(forTypeIdentifier: UTType.movie.identifier) { data, _ in |
| + guard let data else { return } |
| + let name = provider.suggestedName ?? "video" |
| + DispatchQueue.main.async { stageVideoData(data, name: name) } |
| + } |
| + continue |
| + } |
| + if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { |
| + provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in |
| + guard let data else { return } |
| + let name = provider.suggestedName ?? "photo.jpg" |
| + DispatchQueue.main.async { stageImageData(data, name: name) } |
| + } |
| + } |
| + } |
| } |
| @State private var showPhotoPicker = false |
diff --git a/ios/LChatCore/Sources/LChatCore/Blurhash.swift b/ios/LChatCore/Sources/LChatCore/Blurhash.swift| new file mode 100644 |
| index 0000000..32a1ab5 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/Blurhash.swift |
| @@ -0,0 +1,184 @@ |
| +import Foundation |
| +import CoreGraphics |
| + |
| +// 🤖 BlurHash (blurha.sh) — the compact DCT thumbnail that stands in for a |
| +// photo while its blob downloads + decrypts. The hash is DERIVED FROM IMAGE |
| +// CONTENT, so it is user content: it rides inside the encrypted payload as |
| +// `attachments[].bh` (beside w/h/name), never as server metadata. ~30 chars |
| +// of base83 for a 4×3 component hash. |
| +// |
| +// Ported from the reference implementation (github.com/woltapp/blurhash, MIT); |
| +// trimmed to what the app uses: encode from a CGImage, decode to a CGImage. |
| +public enum Blurhash { |
| + private static let alphabet = Array( |
| + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~") |
| + private static let charValue: [Character: Int] = { |
| + var m: [Character: Int] = [:] |
| + for (i, c) in alphabet.enumerated() { m[c] = i } |
| + return m |
| + }() |
| + |
| + // ---- encode -------------------------------------------------------------- |
| + |
| + /// Encode a (small!) CGImage — callers downsample first; DCT cost is |
| + /// O(pixels × components). 4×3 components suit chat thumbnails. |
| + public static func encode(_ image: CGImage, componentsX: Int = 4, componentsY: Int = 3) -> String? { |
| + guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } |
| + let w = image.width, h = image.height |
| + guard w > 0, h > 0 else { return nil } |
| + guard let data = rgbaPixels(image) else { return nil } |
| + |
| + var factors: [(Double, Double, Double)] = [] |
| + for y in 0..<componentsY { |
| + for x in 0..<componentsX { |
| + let normalisation = (x == 0 && y == 0) ? 1.0 : 2.0 |
| + var r = 0.0, g = 0.0, b = 0.0 |
| + for py in 0..<h { |
| + for px in 0..<w { |
| + let basis = normalisation |
| + * cos(Double.pi * Double(x) * Double(px) / Double(w)) |
| + * cos(Double.pi * Double(y) * Double(py) / Double(h)) |
| + let i = (py * w + px) * 4 |
| + r += basis * sRGBToLinear(Int(data[i])) |
| + g += basis * sRGBToLinear(Int(data[i + 1])) |
| + b += basis * sRGBToLinear(Int(data[i + 2])) |
| + } |
| + } |
| + let scale = 1.0 / Double(w * h) |
| + factors.append((r * scale, g * scale, b * scale)) |
| + } |
| + } |
| + |
| + let dc = factors[0] |
| + let ac = factors.dropFirst() |
| + var hash = "" |
| + hash += base83(componentsX - 1 + (componentsY - 1) * 9, length: 1) |
| + let maxAC = ac.reduce(0.0) { m, f in max(m, abs(f.0), abs(f.1), abs(f.2)) } |
| + let quantMax = ac.isEmpty ? 0 : max(0, min(82, Int(floor(maxAC * 166 - 0.5)))) |
| + let acNorm = Double(quantMax + 1) / 166 |
| + hash += base83(quantMax, length: 1) |
| + hash += base83(encodeDC(dc), length: 4) |
| + for f in ac { hash += base83(encodeAC(f, maximum: acNorm), length: 2) } |
| + return hash |
| + } |
| + |
| + // ---- decode -------------------------------------------------------------- |
| + |
| + /// Decode to a tiny CGImage (the renderer scales it up; blurriness IS the |
| + /// look, so 32px on the long edge is plenty). Nil on a malformed hash. |
| + public static func decode(_ hash: String, width: Int = 32, height: Int = 24, |
| + punch: Double = 1) -> CGImage? { |
| + let chars = Array(hash) |
| + guard chars.count >= 6, let sizeFlag = value(chars[0]) else { return nil } |
| + let cy = (sizeFlag / 9) + 1 |
| + let cx = (sizeFlag % 9) + 1 |
| + guard chars.count == 4 + 2 * cx * cy, let quantMax = value(chars[1]) else { return nil } |
| + let maxAC = Double(quantMax + 1) / 166 |
| + |
| + var colours: [(Double, Double, Double)] = [] |
| + guard let dcValue = value(String(chars[2..<6])) else { return nil } |
| + colours.append(decodeDC(dcValue)) |
| + for i in 1..<(cx * cy) { |
| + guard let acValue = value(String(chars[(4 + i * 2)..<(6 + i * 2)])) else { return nil } |
| + colours.append(decodeAC(acValue, maximum: maxAC * punch)) |
| + } |
| + |
| + var pixels = [UInt8](repeating: 255, count: width * height * 4) |
| + for py in 0..<height { |
| + for px in 0..<width { |
| + var r = 0.0, g = 0.0, b = 0.0 |
| + for j in 0..<cy { |
| + for i in 0..<cx { |
| + let basis = cos(Double.pi * Double(px) * Double(i) / Double(width)) |
| + * cos(Double.pi * Double(py) * Double(j) / Double(height)) |
| + let c = colours[i + j * cx] |
| + r += c.0 * basis |
| + g += c.1 * basis |
| + b += c.2 * basis |
| + } |
| + } |
| + let o = (py * width + px) * 4 |
| + pixels[o] = UInt8(linearToSRGB(r)) |
| + pixels[o + 1] = UInt8(linearToSRGB(g)) |
| + pixels[o + 2] = UInt8(linearToSRGB(b)) |
| + } |
| + } |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + guard let provider = CGDataProvider(data: Data(pixels) as CFData) else { return nil } |
| + return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, |
| + bytesPerRow: width * 4, space: cs, |
| + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), |
| + provider: provider, decode: nil, shouldInterpolate: true, |
| + intent: .defaultIntent) |
| + } |
| + |
| + // ---- pieces --------------------------------------------------------------- |
| + |
| + private static func rgbaPixels(_ image: CGImage) -> [UInt8]? { |
| + let w = image.width, h = image.height |
| + var data = [UInt8](repeating: 0, count: w * h * 4) |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + guard let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, |
| + bytesPerRow: w * 4, space: cs, |
| + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) |
| + else { return nil } |
| + ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| + return data |
| + } |
| + |
| + private static func encodeDC(_ c: (Double, Double, Double)) -> Int { |
| + (linearToSRGB(c.0) << 16) + (linearToSRGB(c.1) << 8) + linearToSRGB(c.2) |
| + } |
| + |
| + private static func decodeDC(_ value: Int) -> (Double, Double, Double) { |
| + (sRGBToLinear((value >> 16) & 255), sRGBToLinear((value >> 8) & 255), |
| + sRGBToLinear(value & 255)) |
| + } |
| + |
| + private static func encodeAC(_ c: (Double, Double, Double), maximum: Double) -> Int { |
| + func q(_ v: Double) -> Int { |
| + max(0, min(18, Int(floor(signPow(v / maximum, 0.5) * 9 + 9.5)))) |
| + } |
| + return q(c.0) * 19 * 19 + q(c.1) * 19 + q(c.2) |
| + } |
| + |
| + private static func decodeAC(_ value: Int, maximum: Double) -> (Double, Double, Double) { |
| + func d(_ v: Int) -> Double { signPow((Double(v) - 9) / 9, 2) * maximum } |
| + return (d(value / (19 * 19)), d((value / 19) % 19), d(value % 19)) |
| + } |
| + |
| + private static func signPow(_ v: Double, _ exp: Double) -> Double { |
| + (v < 0 ? -1 : 1) * pow(abs(v), exp) |
| + } |
| + |
| + private static func sRGBToLinear(_ v: Int) -> Double { |
| + let f = Double(v) / 255 |
| + return f <= 0.04045 ? f / 12.92 : pow((f + 0.055) / 1.055, 2.4) |
| + } |
| + |
| + private static func linearToSRGB(_ v: Double) -> Int { |
| + let f = max(0, min(1, v)) |
| + let s = f <= 0.0031308 ? f * 12.92 : 1.055 * pow(f, 1 / 2.4) - 0.055 |
| + return Int(s * 255 + 0.5) |
| + } |
| + |
| + private static func base83(_ value: Int, length: Int) -> String { |
| + var out = "" |
| + for i in 1...length { |
| + let digit = (value / Int(pow(83.0, Double(length - i)))) % 83 |
| + out.append(alphabet[digit]) |
| + } |
| + return out |
| + } |
| + |
| + private static func value(_ c: Character) -> Int? { charValue[c] } |
| + |
| + private static func value(_ s: any StringProtocol) -> Int? { |
| + var out = 0 |
| + for c in s { |
| + guard let v = charValue[c] else { return nil } |
| + out = out * 83 + v |
| + } |
| + return out |
| + } |
| +} |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 7db18ed..b628f68 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -1,6 +1,7 @@ |
| import Foundation |
| import SwiftUI |
| import AVFoundation |
| +import ImageIO |
| // One thread's decrypted message list, folded natively: history via |
| // GET /api/messages + live frames via ChatSocket, every blob opened on-device |
| @@ -218,9 +219,20 @@ public final class ChatFeedModel: ObservableObject { |
| operation: { processImage(data) }).value, |
| processed.bytes.count <= maxUploadBytes, |
| let id = await upload(processed.bytes, onProgress: onProgress) else { return nil } |
| + // The BlurHash placeholder, hashed from a ~32px re-decode of the upload |
| + // bytes (DCT cost is O(pixels) — never hash the full image). |
| + let bh = await Task.detached(priority: .utility, operation: { () -> String? in |
| + guard let src = CGImageSourceCreateWithData(processed.bytes as CFData, nil), |
| + let tiny = CGImageSourceCreateThumbnailAtIndex(src, 0, [ |
| + kCGImageSourceCreateThumbnailFromImageAlways: true, |
| + kCGImageSourceThumbnailMaxPixelSize: 32, |
| + ] as CFDictionary) else { return nil } |
| + return Blurhash.encode(tiny) |
| + }).value |
| let base = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression) |
| return Attachment(id: id, name: base + ".jpg", type: "image/jpeg", |
| - w: processed.w, h: processed.h, size: Int64(processed.bytes.count)) |
| + w: processed.w, h: processed.h, size: Int64(processed.bytes.count), |
| + bh: bh) |
| } |
| /// Stage a video, the web's send-and-play shape: the clip uploads whole |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 274db5f..ee9484a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -774,6 +774,7 @@ public struct MessageBubble: View { |
| @Environment(\.colorScheme) private var scheme |
| @State private var image: PlatformImage? |
| + @State private var blur: PlatformImage? |
| // The thumbnail's live window frame — the lightbox zooms out from here. |
| @State private var globalFrame = CGRect.zero |
| // Animated GIFs only: reduced-motion users get them paused at first. |
| @@ -782,20 +783,28 @@ public struct MessageBubble: View { |
| var body: some View { |
| let box = displaySize() |
| ZStack { |
| - RoundedRectangle(cornerRadius: 18, style: .continuous) |
| - .fill(theme.bubbleThem.resolved(scheme)) |
| + // Under everything: the BlurHash ghost of the photo when the |
| + // payload carries one (native sends do; web sends fall back to |
| + // the flat bubble fill until the web adopts bh). |
| + if let blur { |
| + Image(platform: blur) |
| + .resizable() |
| + .interpolation(.medium) |
| + .frame(width: box.width, height: box.height) |
| + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + } else { |
| + RoundedRectangle(cornerRadius: 18, style: .continuous) |
| + .fill(theme.bubbleThem.resolved(scheme)) |
| + } |
| if let image { |
| - #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| - if isAnimatedGIF(image) { |
| - AnimatedGIFView(image: image, playing: playing) |
| - .frame(width: box.width, height: box.height) |
| - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| - } else { |
| - still(image, box: box) |
| - } |
| - #else |
| - still(image, box: box) |
| - #endif |
| + loadedImage(image, box: box) |
| + // A freshly decrypted photo fades over the placeholder |
| + // (the flash read as a glitch); the fade only animates |
| + // when the load path wraps the assignment in |
| + // withAnimation — cache hits (every scroll-back |
| + // rematerialisation) insert without animation and pop |
| + // instantly, so history never shimmers. |
| + .transition(.opacity) |
| } |
| } |
| .frame(width: box.width, height: box.height) |
| @@ -815,14 +824,39 @@ public struct MessageBubble: View { |
| .task(id: attachment.id) { |
| guard image == nil, let source else { return } |
| if let hit = BlobImageLoader.shared.cached(source.blobID) { |
| - image = hit |
| + image = hit // instant — no fade for cached rematerialisation |
| return |
| } |
| - image = await BlobImageLoader.shared.image(source) |
| + // Decode the BlurHash ghost first (sub-millisecond at 32px) so |
| + // the wait shows the photo's shape, not a grey slab. |
| + if let bh = attachment.bh, blur == nil, |
| + let cg = await Task.detached(operation: { Blurhash.decode(bh) }).value { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + blur = PlatformImage(cgImage: cg, size: .zero) |
| + #else |
| + blur = PlatformImage(cgImage: cg) |
| + #endif |
| + } |
| + guard let loaded = await BlobImageLoader.shared.image(source) else { return } |
| + withAnimation(.easeOut(duration: 0.18)) { image = loaded } |
| } |
| .help(attachment.name ?? "photo") |
| } |
| + @ViewBuilder private func loadedImage(_ image: PlatformImage, box: CGSize) -> some View { |
| + #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| + if isAnimatedGIF(image) { |
| + AnimatedGIFView(image: image, playing: playing) |
| + .frame(width: box.width, height: box.height) |
| + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| + } else { |
| + still(image, box: box) |
| + } |
| + #else |
| + still(image, box: box) |
| + #endif |
| + } |
| + |
| private func still(_ image: PlatformImage, box: CGSize) -> some View { |
| Image(platform: image) |
| .resizable() |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 77041b8..4156cff 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -184,6 +184,9 @@ public struct Attachment: Codable, Hashable, Sendable { |
| public var size: Int64? // bytes, for the file chip's sub-line |
| public var poster: String? // video poster frame's blob id |
| public var dur: Double? // video duration, seconds |
| + // BlurHash placeholder (blurha.sh) — derived from the image, so it's user |
| + // content and rides ONLY inside the encrypted payload, like w/h/name. |
| + public var bh: String? |
| public var isImage: Bool { type?.hasPrefix("image/") ?? false } |
| public var isVideo: Bool { type?.hasPrefix("video/") ?? false } |
| public var isGIF: Bool { type == "image/gif" } // what /gif posts (gif.js) |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/BlurhashTests.swift b/ios/LChatCore/Tests/LChatCoreTests/BlurhashTests.swift| new file mode 100644 |
| index 0000000..37aec5a |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/BlurhashTests.swift |
| @@ -0,0 +1,119 @@ |
| +import XCTest |
| +import CoreGraphics |
| +@testable import LChatCore |
| + |
| +// 🤖 Pins the CGImage adaptation of the reference BlurHash (woltapp/blurhash's |
| +// Swift files are UIImage-only and copy-in by design — no package): structure, |
| +// round-trip colour fidelity, and malformed-hash rejection. A DCT port's bugs |
| +// are subtle; these tests are the confidence the adaptation didn't drift. |
| +final class BlurhashTests: XCTestCase { |
| + private func solid(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, |
| + w: Int = 32, h: Int = 24) -> CGImage { |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, |
| + bytesPerRow: w * 4, space: cs, |
| + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| + // Components in the context's OWN space — the CGColor(red:green:blue:) |
| + // convenience creates a generic-space colour that CG converts on draw |
| + // (0.3 arrived as 0.405), which read as a codec bug. |
| + ctx.setFillColor(CGColor(colorSpace: cs, components: [r, g, b, 1])!) |
| + ctx.fill(CGRect(x: 0, y: 0, width: w, height: h)) |
| + return ctx.makeImage()! |
| + } |
| + |
| + private func averageRGB(_ image: CGImage) -> (Double, Double, Double) { |
| + let w = image.width, h = image.height |
| + var data = [UInt8](repeating: 0, count: w * h * 4) |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, |
| + bytesPerRow: w * 4, space: cs, |
| + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| + ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| + var r = 0.0, g = 0.0, b = 0.0 |
| + for i in stride(from: 0, to: data.count, by: 4) { |
| + r += Double(data[i]); g += Double(data[i + 1]); b += Double(data[i + 2]) |
| + } |
| + let n = Double(w * h) |
| + return (r / n, g / n, b / n) |
| + } |
| + |
| + func testHashShape() throws { |
| + let hash = try XCTUnwrap(Blurhash.encode(solid(0.8, 0.3, 0.2))) |
| + // 4×3 components: 1 (size) + 1 (max AC) + 4 (DC) + 2×11 (AC) = 28 chars. |
| + XCTAssertEqual(hash.count, 28) |
| + } |
| + |
| + // The shared vector grid: 8×6 RGBA, red ramps left→right, green top→bottom, |
| + // blue fixed at 64 — keep IDENTICAL to vectorGrid() in test/blurhash.test.mjs. |
| + private func vectorImage() -> CGImage { |
| + let w = 8, h = 6 |
| + var px = [UInt8](repeating: 0, count: w * h * 4) |
| + for y in 0..<h { |
| + for x in 0..<w { |
| + let o = (y * w + x) * 4 |
| + px[o] = UInt8((x * 255 + (w - 1) / 2) / (w - 1)) |
| + px[o + 1] = UInt8((y * 255 + (h - 1) / 2) / (h - 1)) |
| + px[o + 2] = 64 |
| + px[o + 3] = 255 |
| + } |
| + } |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + let provider = CGDataProvider(data: Data(px) as CFData)! |
| + return CGImage(width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32, |
| + bytesPerRow: w * 4, space: cs, |
| + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), |
| + provider: provider, decode: nil, shouldInterpolate: false, |
| + intent: .defaultIntent)! |
| + } |
| + |
| + func testCrossImplementationVector() throws { |
| + // The one true hash for the vector grid — test/blurhash.test.mjs pins |
| + // the same string. A change here without the twin is a platform drift. |
| + XCTAssertEqual(try XCTUnwrap(Blurhash.encode(vectorImage())), |
| + "LyI5d=39a|%0z2NKfQnSeXf7fQf7") |
| + } |
| + |
| + func testRoundTripPreservesColour() throws { |
| + for (r, g, b) in [(1.0, 0.2, 0.2), (0.1, 0.7, 0.3), (0.2, 0.3, 0.9)] { |
| + let hash = try XCTUnwrap(Blurhash.encode(solid(r, g, b))) |
| + let decoded = try XCTUnwrap(Blurhash.decode(hash)) |
| + let avg = averageRGB(decoded) |
| + XCTAssertEqual(avg.0 / 255, r, accuracy: 0.06, "red channel for \(hash)") |
| + XCTAssertEqual(avg.1 / 255, g, accuracy: 0.06, "green channel for \(hash)") |
| + XCTAssertEqual(avg.2 / 255, b, accuracy: 0.06, "blue channel for \(hash)") |
| + } |
| + } |
| + |
| + func testGradientRoundTripKeepsOrientation() throws { |
| + // Left-red, right-blue: the decode must keep the gradient's direction. |
| + let w = 32, h = 24 |
| + let cs = CGColorSpaceCreateDeviceRGB() |
| + let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, |
| + bytesPerRow: w * 4, space: cs, |
| + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| + ctx.setFillColor(CGColor(colorSpace: cs, components: [1, 0, 0, 1])!) |
| + ctx.fill(CGRect(x: 0, y: 0, width: w / 2, height: h)) |
| + ctx.setFillColor(CGColor(colorSpace: cs, components: [0, 0, 1, 1])!) |
| + ctx.fill(CGRect(x: w / 2, y: 0, width: w / 2, height: h)) |
| + let hash = try XCTUnwrap(Blurhash.encode(ctx.makeImage()!)) |
| + let decoded = try XCTUnwrap(Blurhash.decode(hash, width: 32, height: 24)) |
| + var left = [UInt8](repeating: 0, count: 4), right = left |
| + let dctx = { (px: Int, out: inout [UInt8]) in |
| + let c = CGContext(data: &out, width: 1, height: 1, bitsPerComponent: 8, |
| + bytesPerRow: 4, space: cs, |
| + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| + c.draw(decoded, in: CGRect(x: -px, y: 0, width: 32, height: 24)) |
| + } |
| + dctx(2, &left) |
| + dctx(29, &right) |
| + XCTAssertGreaterThan(left[0], left[2], "left edge reads red") |
| + XCTAssertGreaterThan(right[2], right[0], "right edge reads blue") |
| + } |
| + |
| + func testMalformedHashesRejected() { |
| + XCTAssertNil(Blurhash.decode("")) |
| + XCTAssertNil(Blurhash.decode("LEHV6")) // truncated |
| + XCTAssertNil(Blurhash.decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj!!")) // wrong length |
| + XCTAssertNil(Blurhash.decode(String(repeating: "\u{1F600}", count: 28))) // non-base83 |
| + } |
| +} |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 3a953e8..ead36d8 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -1,6 +1,7 @@ |
| import LChatCore |
| import SwiftUI |
| import AVFoundation |
| +import os |
| // One thread's natively-folded feed — the real native chat surface (mounted by |
| // the takeover's NativeChatView and the Debug diff pane alike), rendered with |
| // the shared MessageBubble leaf views. |
| @@ -145,8 +146,6 @@ struct NativeFeedView: View { |
| // True while a horizontal swipe is actively dragging the gutter — the |
| // zero-delta release event snaps only when a drag was in flight. |
| @State private var draggingTimes = false |
| - // A Finder drag is hovering the feed (drop-to-attach highlight). |
| - @State private var dropTargeted = false |
| // The bubble stage is sized from the VIEWPORT (minus row padding 20, run |
| // spacer 48, and the gutter — reserved whether or not it's on screen, so |
| // revealing times never resizes a bubble), capped at a readable measure. |
| @@ -418,38 +417,27 @@ struct NativeFeedView: View { |
| if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) } |
| swipeMonitor = nil |
| } |
| - // Drop-to-attach: files dragged from the Finder stage exactly like the |
| - // + button's picks (downscale/allowlist/encrypt), with the web's |
| - // drop-overlay highlight while hovering. |
| + // Drop-to-attach: anything draggable stages exactly like the + button's |
| + // picks — Finder files (fileURL), app-vended images (CleanShot, Photos: |
| + // raw image data and/or a FILE PROMISE, which is why a fileURL-only |
| + // accept list bounced them), movies. No overlay chrome: the system's |
| + // own + drag badge is the affordance (Paul's call — idiomatic macOS). |
| // In a default-command thread (text-only surface) the type list is |
| - // empty, so the feed is NOT a drop target — no "Drop to attach" |
| - // highlight inviting a drop that would only be refused. |
| - .onDrop(of: defaultCommand == nil ? [.fileURL] : [], isTargeted: $dropTargeted) { providers in |
| - for provider in providers { |
| - _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| - guard let url else { return } |
| - DispatchQueue.main.async { stage(url) } |
| - } |
| - } |
| + // empty, so the feed is not a drop target at all. |
| + .onDrop(of: defaultCommand == nil ? Self.droppableTypes : [], isTargeted: nil) { providers in |
| + stageProviders(providers) |
| return true |
| } |
| - .overlay { |
| - if dropTargeted { |
| - RoundedRectangle(cornerRadius: 14, style: .continuous) |
| - .strokeBorder(Color.accentColor, style: StrokeStyle(lineWidth: 2, dash: [7, 5])) |
| - .background(RoundedRectangle(cornerRadius: 14, style: .continuous) |
| - .fill(Color.accentColor.opacity(0.06))) |
| - .overlay { |
| - Label("Drop to attach", systemImage: "paperclip") |
| - .font(.system(size: 14, weight: .semibold)) |
| - .foregroundStyle(Color.accentColor) |
| - } |
| - .padding(8) |
| - .allowsHitTesting(false) |
| - } |
| - } |
| } |
| + // What the feed accepts on a drop: real files, promised files (how most |
| + // apps vend drags — screenshots, Photos), and raw image/movie data. |
| + static let droppableTypes: [UTType] = { |
| + var types: [UTType] = [.fileURL, .image, .movie] |
| + types += NSFilePromiseReceiver.readableDraggedTypes.compactMap { UTType($0) } |
| + return types |
| + }() |
| + |
| static let bottomSentinel = "bottom-sentinel" |
| // The feed stays INVISIBLE until the pin is verified flush — the reader |
| @@ -498,6 +486,17 @@ struct NativeFeedView: View { |
| } |
| if let f = frames.rows[id], f.maxY <= frames.viewport.height + 2 { |
| settled = true // flush — reveal |
| + // Flush NOW isn't flush in a beat: a send clears the staged |
| + // strip a moment after this verify passes, the composer inset |
| + // shrinks, and the content shifts under the satisfied pin — |
| + // an image bubble lands below the fold (Paul's screenshot). |
| + // Confirm the pin HELD; if the layout moved, keep pinning. |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { |
| + guard model.messages.last?.id == id, |
| + let f2 = frames.rows[id], |
| + f2.maxY > frames.viewport.height + 2 else { return } |
| + pinUntilSettled(proxy, attempts: max(attempts - 1, 2), rescues: rescues) |
| + } |
| return |
| } |
| if frames.rows.isEmpty, let first = model.messages.first?.id { |
| @@ -1367,25 +1366,46 @@ struct NativeFeedView: View { |
| } |
| } |
| - // Pasted content: file URLs route through stage(url); raw image data |
| - // stages as a PNG (like the web composer's paste-to-upload). |
| + // Pasted content routes through the same provider pipeline as drops. |
| private func handlePaste(_ providers: [NSItemProvider]) { |
| + stageProviders(providers) |
| + } |
| + |
| + // ONE pipeline for every provider source (paste, drop): a real file URL |
| + // stages directly; anything else that reads as an image or movie is |
| + // materialised via loadFileRepresentation — which works whether the app |
| + // vended raw data or a file promise (CleanShot, Photos: the provider |
| + // fulfils the promise into the temp file it hands us). The temp URL dies |
| + // when the closure returns, so the bytes are read inside it. |
| + private func stageProviders(_ providers: [NSItemProvider]) { |
| for provider in providers { |
| + // Verification tap (names, never content) — drags arrive in wildly |
| + // different shapes per source app; this is how the next "my drag |
| + // doesn't work" gets diagnosed without an instrumented build. |
| + Logger.identity.info("drop provider types: \(provider.registeredTypeIdentifiers.joined(separator: ", "), privacy: .public) suggested: \(provider.suggestedName ?? "-", privacy: .public)") |
| if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| if let url { DispatchQueue.main.async { stage(url) } } |
| } |
| continue |
| } |
| - let types: [UTType] = [.png, .jpeg, .tiff, .image] |
| + let types: [UTType] = [.png, .jpeg, .tiff, .heic, .image, .movie] |
| guard let type = types.first(where: { |
| provider.hasItemConformingToTypeIdentifier($0.identifier) |
| }) else { continue } |
| - provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in |
| - guard let data else { return } |
| - DispatchQueue.main.async { |
| - stage(data: data, name: "pasted.png", type: .png) |
| + _ = provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in |
| + if let error { |
| + Logger.identity.error("drop fileRep failed: \(String(describing: error), privacy: .public)") |
| } |
| + guard let url, let data = try? Data(contentsOf: url) else { return } |
| + // Prefer the drag's human name; the temp file supplies the |
| + // extension (its name is machine-generated but correctly typed). |
| + let ext = url.pathExtension |
| + let base = provider.suggestedName ?? url.deletingPathExtension().lastPathComponent |
| + let name = ext.isEmpty || base.lowercased().hasSuffix("." + ext.lowercased()) |
| + ? base : base + "." + ext |
| + let staged = UTType(filenameExtension: url.pathExtension) ?? type |
| + DispatchQueue.main.async { stage(data: data, name: name, type: staged) } |
| } |
| } |
| } |
diff --git a/package.json b/package.json| index 75ac457..eff7a9c 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |
diff --git a/test/blurhash.test.mjs b/test/blurhash.test.mjs| new file mode 100644 |
| index 0000000..3b16a3a |
| --- /dev/null |
| +++ b/test/blurhash.test.mjs |
| @@ -0,0 +1,86 @@ |
| +// 🤖 Tests for the web BlurHash codec (web/static/blurhash.js) — including THE |
| +// cross-implementation vector: the Swift twin (Blurhash.swift) encodes the |
| +// same deterministic pixel grid in BlurhashTests.swift and must produce the |
| +// SAME hash. If either side changes output, both tests fail together. |
| +// node test/blurhash.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { encodeBlurhash, decodeBlurhash } from "../web/static/blurhash.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +// The shared vector grid: 8×6, RGBA, deterministic bytes — red ramps left to |
| +// right, green top to bottom, blue fixed. Keep IDENTICAL to vectorImage() in |
| +// BlurhashTests.swift. |
| +function vectorGrid() { |
| + const w = 8, h = 6; |
| + const px = new Uint8ClampedArray(w * h * 4); |
| + for (let y = 0; y < h; y++) { |
| + for (let x = 0; x < w; x++) { |
| + const o = (y * w + x) * 4; |
| + px[o] = Math.round((x * 255) / (w - 1)); |
| + px[o + 1] = Math.round((y * 255) / (h - 1)); |
| + px[o + 2] = 64; |
| + px[o + 3] = 255; |
| + } |
| + } |
| + return { px, w, h }; |
| +} |
| + |
| +await test("cross-implementation vector (pinned against Blurhash.swift)", () => { |
| + const { px, w, h } = vectorGrid(); |
| + // The one true hash for the vector grid — BlurhashTests.swift pins the same |
| + // string. A change here without the twin is a cross-platform drift. |
| + assert.equal(encodeBlurhash(px, w, h), "LyI5d=39a|%0z2NKfQnSeXf7fQf7"); |
| +}); |
| + |
| +await test("solid colour round-trips through encode/decode", () => { |
| + const w = 16, h = 12; |
| + const px = new Uint8ClampedArray(w * h * 4); |
| + for (let i = 0; i < px.length; i += 4) { |
| + px[i] = 200; px[i + 1] = 60; px[i + 2] = 40; px[i + 3] = 255; |
| + } |
| + const hash = encodeBlurhash(px, w, h); |
| + const out = decodeBlurhash(hash, 16, 12); |
| + let r = 0, g = 0, b = 0; |
| + for (let i = 0; i < out.length; i += 4) { r += out[i]; g += out[i + 1]; b += out[i + 2]; } |
| + const n = w * h; |
| + assert.ok(Math.abs(r / n - 200) < 12, `red ${r / n}`); |
| + assert.ok(Math.abs(g / n - 60) < 12, `green ${g / n}`); |
| + assert.ok(Math.abs(b / n - 40) < 12, `blue ${b / n}`); |
| +}); |
| + |
| +await test("gradient keeps its direction", () => { |
| + const w = 16, h = 12; |
| + const px = new Uint8ClampedArray(w * h * 4); |
| + for (let y = 0; y < h; y++) { |
| + for (let x = 0; x < w; x++) { |
| + const o = (y * w + x) * 4; |
| + px[o] = x < w / 2 ? 255 : 0; |
| + px[o + 2] = x < w / 2 ? 0 : 255; |
| + px[o + 3] = 255; |
| + } |
| + } |
| + const out = decodeBlurhash(encodeBlurhash(px, w, h), 16, 12); |
| + const at = (x, y) => out.slice((y * 16 + x) * 4, (y * 16 + x) * 4 + 4); |
| + const left = at(1, 6), right = at(14, 6); |
| + assert.ok(left[0] > left[2], "left reads red"); |
| + assert.ok(right[2] > right[0], "right reads blue"); |
| +}); |
| + |
| +await test("malformed hashes are rejected", () => { |
| + assert.equal(decodeBlurhash(""), null); |
| + assert.equal(decodeBlurhash("LEHV6"), null); |
| + assert.equal(decodeBlurhash("LEHV6nWB2yk8pyo0adR*.7kCMdnj!!"), null); |
| +}); |
| + |
| +console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED"); |
| +process.exit(failures ? 1 : 0); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 82a0184..5c0a65f 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -27,6 +27,7 @@ import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, fir |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, ownMessage } from "../ui.js"; |
| +import { decodeBlurhash } from "../blurhash.js"; |
| // The host bridge: chat.js injects the pieces that are server mechanisms, |
| // key-holding machinery, or host state — the app never touches crypto/tokens. |
| @@ -880,6 +881,24 @@ function renderItem(m, ctx) { |
| const scale = Math.min(260 / a.w, 320 / a.h, 1); |
| img.style.width = Math.round(a.w * scale) + "px"; |
| } |
| + // The BlurHash ghost of the photo while its blob downloads + decrypts |
| + // (attachments[].bh, inside the encrypted payload): decoded into a tiny |
| + // canvas and painted as the element's background — the decoded pixels |
| + // simply cover it, and matching colours make the swap read gently. |
| + if (a.bh) { |
| + try { |
| + const pw = 32, ph = a.w && a.h ? Math.max(1, Math.round((32 * a.h) / a.w)) : 24; |
| + const px = decodeBlurhash(a.bh, pw, ph); |
| + if (px) { |
| + const c = document.createElement("canvas"); |
| + c.width = pw; |
| + c.height = ph; |
| + c.getContext("2d").putImageData(new ImageData(px, pw, ph), 0, 0); |
| + img.style.backgroundImage = `url(${c.toDataURL()})`; |
| + img.style.backgroundSize = "cover"; |
| + } |
| + } catch { /* a bad hash keeps the flat placeholder */ } |
| + } |
| if (host) { |
| host.loadBlob(ctx.threadId, a).then((url) => { |
| if (!url) return; |
diff --git a/web/static/blurhash.js b/web/static/blurhash.js| new file mode 100644 |
| index 0000000..fa4e752 |
| --- /dev/null |
| +++ b/web/static/blurhash.js |
| @@ -0,0 +1,137 @@ |
| +// 🤖 BlurHash (blurha.sh) — the compact DCT thumbnail that stands in for a |
| +// photo while its blob downloads + decrypts. The hash is derived from image |
| +// content, so it's user content: it rides inside the encrypted payload as |
| +// attachments[].bh (beside w/h/name), never as server metadata. |
| +// |
| +// Ported from the reference implementation (github.com/woltapp/blurhash, MIT). |
| +// The native twin is ios/LChatCore/Sources/LChatCore/Blurhash.swift — the two |
| +// are pinned to the same output by the shared vector in test/blurhash.test.mjs |
| +// and BlurhashTests.swift (encode the same pixel grid, expect the same hash). |
| + |
| +const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; |
| + |
| +const sRGBToLinear = (v) => { |
| + const f = v / 255; |
| + return f <= 0.04045 ? f / 12.92 : Math.pow((f + 0.055) / 1.055, 2.4); |
| +}; |
| + |
| +const linearToSRGB = (v) => { |
| + const f = Math.max(0, Math.min(1, v)); |
| + const s = f <= 0.0031308 ? f * 12.92 : 1.055 * Math.pow(f, 1 / 2.4) - 0.055; |
| + return Math.round(s * 255); |
| +}; |
| + |
| +const signPow = (v, exp) => (v < 0 ? -1 : 1) * Math.pow(Math.abs(v), exp); |
| + |
| +function base83(value, length) { |
| + let out = ""; |
| + for (let i = 1; i <= length; i++) { |
| + const digit = Math.floor(value / Math.pow(83, length - i)) % 83; |
| + out += ALPHABET[digit]; |
| + } |
| + return out; |
| +} |
| + |
| +function value83(str) { |
| + let out = 0; |
| + for (const c of str) { |
| + const v = ALPHABET.indexOf(c); |
| + if (v < 0) return null; |
| + out = out * 83 + v; |
| + } |
| + return out; |
| +} |
| + |
| +// Encode RGBA pixels (Uint8ClampedArray, e.g. ImageData.data). Callers pass a |
| +// SMALL grid (~32px long edge) — DCT cost is O(pixels × components). |
| +export function encodeBlurhash(pixels, width, height, componentsX = 4, componentsY = 3) { |
| + if (componentsX < 1 || componentsX > 9 || componentsY < 1 || componentsY > 9) return null; |
| + if (!width || !height || pixels.length < width * height * 4) return null; |
| + |
| + const factors = []; |
| + for (let y = 0; y < componentsY; y++) { |
| + for (let x = 0; x < componentsX; x++) { |
| + const normalisation = x === 0 && y === 0 ? 1 : 2; |
| + let r = 0, g = 0, b = 0; |
| + for (let py = 0; py < height; py++) { |
| + for (let px = 0; px < width; px++) { |
| + const basis = normalisation |
| + * Math.cos((Math.PI * x * px) / width) |
| + * Math.cos((Math.PI * y * py) / height); |
| + const i = (py * width + px) * 4; |
| + r += basis * sRGBToLinear(pixels[i]); |
| + g += basis * sRGBToLinear(pixels[i + 1]); |
| + b += basis * sRGBToLinear(pixels[i + 2]); |
| + } |
| + } |
| + const scale = 1 / (width * height); |
| + factors.push([r * scale, g * scale, b * scale]); |
| + } |
| + } |
| + |
| + const dc = factors[0]; |
| + const ac = factors.slice(1); |
| + let hash = base83(componentsX - 1 + (componentsY - 1) * 9, 1); |
| + const maxAC = ac.reduce((m, f) => Math.max(m, Math.abs(f[0]), Math.abs(f[1]), Math.abs(f[2])), 0); |
| + const quantMax = ac.length ? Math.max(0, Math.min(82, Math.floor(maxAC * 166 - 0.5))) : 0; |
| + const acNorm = (quantMax + 1) / 166; |
| + hash += base83(quantMax, 1); |
| + hash += base83( |
| + (linearToSRGB(dc[0]) << 16) + (linearToSRGB(dc[1]) << 8) + linearToSRGB(dc[2]), 4); |
| + for (const f of ac) { |
| + const q = (v) => Math.max(0, Math.min(18, Math.floor(signPow(v / acNorm, 0.5) * 9 + 9.5))); |
| + hash += base83(q(f[0]) * 19 * 19 + q(f[1]) * 19 + q(f[2]), 2); |
| + } |
| + return hash; |
| +} |
| + |
| +// Decode to RGBA pixels (Uint8ClampedArray) — put into a small canvas and let |
| +// CSS scale it up; the blurriness IS the look. Null on a malformed hash. |
| +export function decodeBlurhash(hash, width = 32, height = 24, punch = 1) { |
| + if (!hash || hash.length < 6) return null; |
| + const sizeFlag = value83(hash[0]); |
| + if (sizeFlag == null) return null; |
| + const cy = Math.floor(sizeFlag / 9) + 1; |
| + const cx = (sizeFlag % 9) + 1; |
| + if (hash.length !== 4 + 2 * cx * cy) return null; |
| + const quantMax = value83(hash[1]); |
| + if (quantMax == null) return null; |
| + const maxAC = ((quantMax + 1) / 166) * punch; |
| + |
| + const colours = []; |
| + const dcValue = value83(hash.slice(2, 6)); |
| + if (dcValue == null) return null; |
| + colours.push([ |
| + sRGBToLinear((dcValue >> 16) & 255), |
| + sRGBToLinear((dcValue >> 8) & 255), |
| + sRGBToLinear(dcValue & 255), |
| + ]); |
| + for (let i = 1; i < cx * cy; i++) { |
| + const acValue = value83(hash.slice(4 + i * 2, 6 + i * 2)); |
| + if (acValue == null) return null; |
| + const d = (v) => signPow((v - 9) / 9, 2) * maxAC; |
| + colours.push([d(Math.floor(acValue / (19 * 19))), d(Math.floor(acValue / 19) % 19), d(acValue % 19)]); |
| + } |
| + |
| + const pixels = new Uint8ClampedArray(width * height * 4); |
| + for (let py = 0; py < height; py++) { |
| + for (let px = 0; px < width; px++) { |
| + let r = 0, g = 0, b = 0; |
| + for (let j = 0; j < cy; j++) { |
| + for (let i = 0; i < cx; i++) { |
| + const basis = Math.cos((Math.PI * px * i) / width) * Math.cos((Math.PI * py * j) / height); |
| + const c = colours[i + j * cx]; |
| + r += c[0] * basis; |
| + g += c[1] * basis; |
| + b += c[2] * basis; |
| + } |
| + } |
| + const o = (py * width + px) * 4; |
| + pixels[o] = linearToSRGB(r); |
| + pixels[o + 1] = linearToSRGB(g); |
| + pixels[o + 2] = linearToSRGB(b); |
| + pixels[o + 3] = 255; |
| + } |
| + } |
| + return pixels; |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 584cbb3..ad6b6aa 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -46,6 +46,7 @@ import { |
| import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js"; |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { startBootScene, finishBootScene } from "/static/bootscene.js"; |
| +import { encodeBlurhash } from "/static/blurhash.js"; |
| import { MESSAGE_TYPE_TYPING } from "/static/message.js"; |
| import { |
| setEmitTransport, |
| @@ -3293,10 +3294,12 @@ async function chatAppUpload(threadId, file, onProgress) { |
| return uploadEncrypted(threadId, enc, onProg); |
| }; |
| if (isImage) { |
| - const { bytes, type, w, h } = await processImage(file); |
| + const { bytes, type, w, h, bh } = await processImage(file); |
| if (bytes.length > MAX_UPLOAD) return "photo"; |
| const id = await post(bytes); |
| - return { id, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg", type, w, h }; |
| + const att = { id, name: (file.name || "photo").replace(/\.[^.]+$/, "") + ".jpg", type, w, h }; |
| + if (bh) att.bh = bh; |
| + return att; |
| } |
| if (isVideo) { |
| // Send-and-play: encrypt the clip whole + upload it, plus a client-extracted |
| @@ -4554,7 +4557,23 @@ async function processImage(file, maxDim = MAX_IMAGE_DIM) { |
| canvas.height = h; |
| canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h); |
| const out = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.85)); |
| - return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h }; |
| + // The BlurHash placeholder, from a tiny second draw (DCT cost is O(pixels) — |
| + // hash a ~32px thumbnail, never the full image). Rides INSIDE the encrypted |
| + // payload (attachments[].bh) like w/h — it's derived from image content. |
| + let bh = null; |
| + try { |
| + const ts = Math.min(1, 32 / Math.max(w, h)); |
| + const tw = Math.max(1, Math.round(w * ts)); |
| + const th = Math.max(1, Math.round(h * ts)); |
| + const tiny = document.createElement("canvas"); |
| + tiny.width = tw; |
| + tiny.height = th; |
| + tiny.getContext("2d").drawImage(bitmap, 0, 0, tw, th); |
| + bh = encodeBlurhash(tiny.getContext("2d").getImageData(0, 0, tw, th).data, tw, th); |
| + } catch { |
| + // No hash is fine — receivers keep the flat placeholder. |
| + } |
| + return { bytes: new Uint8Array(await out.arrayBuffer()), type: "image/jpeg", w, h, bh }; |
| } |
| // Stage one attachment. Several can ride a single message, so each gets its own |