diff --git a/Flipcash/Core/Controllers/Database/Database+Conversations.swift b/Flipcash/Core/Controllers/Database/Database+Conversations.swift index 7279d5aa..6ddac127 100644 --- a/Flipcash/Core/Controllers/Database/Database+Conversations.swift +++ b/Flipcash/Core/Controllers/Database/Database+Conversations.swift @@ -300,7 +300,8 @@ nonisolated extension Database { m.readPointer <- member.readPointer?.value, m.readPointerTimestamp <- member.readPointerTimestamp?.timeIntervalSinceReferenceDate, m.profilePictureBlobID <- member.profilePicture?.blobID.data, - m.profilePictureThumbnailBlobID <- member.profilePicture?.thumbnailBlobID.data + m.profilePictureThumbnailBlobID <- member.profilePicture?.thumbnailBlobID.data, + m.profilePictureThumbnailBlurhash <- member.profilePicture?.thumbnailBlurhash ) ) } @@ -393,7 +394,8 @@ nonisolated extension Database { } return ProfilePicture( blobID: BlobID(data: blobID), - thumbnailBlobID: BlobID(data: thumbnailBlobID) + thumbnailBlobID: BlobID(data: thumbnailBlobID), + thumbnailBlurhash: row[m.profilePictureThumbnailBlurhash] ) } diff --git a/Flipcash/Core/Controllers/Database/Schema.swift b/Flipcash/Core/Controllers/Database/Schema.swift index cc64abbc..e3db86f6 100644 --- a/Flipcash/Core/Controllers/Database/Schema.swift +++ b/Flipcash/Core/Controllers/Database/Schema.swift @@ -187,8 +187,10 @@ nonisolated struct ConversationMemberTable: Sendable { let readPointer = Expression ("readPointer") let readPointerTimestamp = Expression ("readPointerTimestamp") // Profile-picture rendition blob ids, when the member has a picture. - let profilePictureBlobID = Expression ("profilePictureBlobID") - let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") + let profilePictureBlobID = Expression ("profilePictureBlobID") + let profilePictureThumbnailBlobID = Expression ("profilePictureThumbnailBlobID") + // The thumbnail rendition's BlurHash preview, when present. + let profilePictureThumbnailBlurhash = Expression ("profilePictureThumbnailBlurhash") } // One row per message; cash content is decomposed across the amount columns @@ -406,6 +408,7 @@ nonisolated extension Database { t.column(conversationMemberTable.readPointerTimestamp) t.column(conversationMemberTable.profilePictureBlobID) t.column(conversationMemberTable.profilePictureThumbnailBlobID) + t.column(conversationMemberTable.profilePictureThumbnailBlurhash) }) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 2b2d5a87..ff162543 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -184,6 +184,7 @@ struct ConversationScreen: View { contact: contact, conversationID: conversationID, imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), + blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, width: max(navBarWidth - Self.titleSideInset * 2, 0), onTap: titleTapAction ) @@ -395,6 +396,7 @@ struct ConversationScreen: View { name: controller.displayName(for: conversation), avatarID: counterpart?.userID?.uuidString ?? conversationID.description, imageData: tipAvatars.data(for: counterpart?.userID), + blurhash: counterpart?.profilePicture?.thumbnailBlurhash, counterpart: .tipcode ) } @@ -456,6 +458,7 @@ private struct ConversationTitleItem: View { let contact: ResolvedContact? let conversationID: ConversationID? let imageData: Data? + let blurhash: String? let width: CGFloat let onTap: (() -> Void)? @@ -465,6 +468,7 @@ private struct ConversationTitleItem: View { contact: contact, conversationID: conversationID, imageData: imageData, + blurhash: blurhash, width: width ) if let onTap { @@ -484,6 +488,7 @@ private struct ConversationTitleLabel: View { let contact: ResolvedContact? let conversationID: ConversationID? let imageData: Data? + let blurhash: String? let width: CGFloat var body: some View { @@ -492,6 +497,7 @@ private struct ConversationTitleLabel: View { id: contact?.contactId ?? conversationID?.description ?? title, displayName: title, imageData: imageData, + blurhash: blurhash, size: 44 ) .accessibilityHidden(true) diff --git a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift index 4399d3c7..4bcf166f 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift @@ -67,6 +67,7 @@ private struct TipConversationRow: View { title: title, subtitle: subtitle, imageData: sessionContainer.tipAvatars.data(for: counterpart?.userID), + blurhash: counterpart?.profilePicture?.thumbnailBlurhash, accessoryPlacement: .titleLine, accessibilityLabel: hasUnread ? "\(title), unread messages" : title, onTap: onTap diff --git a/Flipcash/Core/Screens/Send/RecipientPickerScreen.swift b/Flipcash/Core/Screens/Send/RecipientPickerScreen.swift index 4696b2c5..c6e7df58 100644 --- a/Flipcash/Core/Screens/Send/RecipientPickerScreen.swift +++ b/Flipcash/Core/Screens/Send/RecipientPickerScreen.swift @@ -392,6 +392,7 @@ struct RecipientRowScaffold: View { let title: String let subtitle: String? let imageData: Data? + var blurhash: String? = nil var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn let accessibilityLabel: String let onTap: () -> Void @@ -404,6 +405,7 @@ struct RecipientRowScaffold: View { title: title, subtitle: subtitle, imageData: imageData, + blurhash: blurhash, accessoryPlacement: accessoryPlacement ) { trailing @@ -422,6 +424,7 @@ struct RecipientRowBody: View { let title: String let subtitle: String? let imageData: Data? + var blurhash: String? = nil var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn @ViewBuilder let trailing: Trailing @@ -431,6 +434,7 @@ struct RecipientRowBody: View { id: avatarID, displayName: title, imageData: imageData, + blurhash: blurhash, size: 44 ) VStack(alignment: .leading, spacing: 2) { diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index 2171686f..426aa342 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -23,7 +23,7 @@ com.flipcash.app.openChat SQLiteVersion - 25 + 26 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift index 313b1f55..455a68a1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatProfileCard.swift @@ -16,6 +16,9 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { public var avatarID: String /// The contact's address-book thumbnail; nil renders the monogram placeholder. public var imageData: Data? + /// The counterpart's profile-picture BlurHash, shown as a blurred preview while + /// their avatar bytes load. Nil for address-book contacts. + public var blurhash: String? public var counterpart: Counterpart /// How the counterpart relates to the address book. @@ -29,10 +32,11 @@ public struct ChatProfileCard: Hashable, Sendable, Codable { case tipcode } - public init(name: String, avatarID: String, imageData: Data?, counterpart: Counterpart) { + public init(name: String, avatarID: String, imageData: Data?, blurhash: String? = nil, counterpart: Counterpart) { self.name = name self.avatarID = avatarID self.imageData = imageData + self.blurhash = blurhash self.counterpart = counterpart } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/ProfilePicture.swift b/FlipcashCore/Sources/FlipcashCore/Models/ProfilePicture.swift index d73c9b90..24c6a983 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/ProfilePicture.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/ProfilePicture.swift @@ -24,9 +24,14 @@ public struct ProfilePicture: Codable, Hashable, Sendable { /// The blob holding the avatar-sized rendition. public let thumbnailBlobID: BlobID - public init(blobID: BlobID, thumbnailBlobID: BlobID) { - self.blobID = blobID - self.thumbnailBlobID = thumbnailBlobID + /// The thumbnail rendition's BlurHash, a compact blurred preview shown while + /// the bytes download. Nil when the server carried none. + public let thumbnailBlurhash: String? + + public init(blobID: BlobID, thumbnailBlobID: BlobID, thumbnailBlurhash: String? = nil) { + self.blobID = blobID + self.thumbnailBlobID = thumbnailBlobID + self.thumbnailBlurhash = thumbnailBlurhash } } @@ -44,7 +49,8 @@ extension ProfilePicture { self.init( blobID: blobID, - thumbnailBlobID: try container.decodeIfPresent(BlobID.self, forKey: .thumbnailBlobID) ?? blobID + thumbnailBlobID: try container.decodeIfPresent(BlobID.self, forKey: .thumbnailBlobID) ?? blobID, + thumbnailBlurhash: try container.decodeIfPresent(String.self, forKey: .thumbnailBlurhash) ) } } @@ -68,7 +74,8 @@ extension ProfilePicture { self.init( blobID: BlobID(data: original.blobID.value), - thumbnailBlobID: BlobID(data: thumbnail.blobID.value) + thumbnailBlobID: BlobID(data: thumbnail.blobID.value), + thumbnailBlurhash: thumbnail.blurhash ) } } @@ -87,4 +94,10 @@ private extension Flipcash_Blob_V1_Rendition { var pixelWidth: UInt32 { hasBlob ? blob.image.width : 0 } + + /// The rendition's BlurHash preview, or nil when the blob carries none. + var blurhash: String? { + guard hasBlob, !blob.image.blurhash.isEmpty else { return nil } + return blob.image.blurhash + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ProfilePictureTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ProfilePictureTests.swift index 41e9d9eb..f4ac3c3a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ProfilePictureTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ProfilePictureTests.swift @@ -71,6 +71,60 @@ struct ProfilePictureTests { #expect(ProfilePicture(Flipcash_Blob_V1_Media()) == nil) } + /// The BlurHash is read off the same rendition chosen as the thumbnail — the + /// largest one — so the preview matches the bytes that will load. + @Test("Carries the selected thumbnail's BlurHash") + func carriesSelectedThumbnailBlurhash() throws { + let media = Self.media([ + (role: .original, id: 1, width: 1600, blurhash: ""), + (role: .thumbnail, id: 2, width: 64, blurhash: "small"), + (role: .thumbnail, id: 3, width: 320, blurhash: "large"), + ]) + + let picture = try #require(ProfilePicture(media)) + + #expect(picture.thumbnailBlobID == Self.blobID(3)) + #expect(picture.thumbnailBlurhash == "large") + } + + /// Legacy or still-processing media may carry no BlurHash; the empty proto + /// default maps to nil rather than an empty string. + @Test("BlurHash is nil when the thumbnail carries none") + func nilBlurhashWhenAbsent() throws { + let media = Self.media([ + (role: .original, id: 1, width: 1600, blurhash: ""), + (role: .thumbnail, id: 2, width: 320, blurhash: ""), + ]) + + let picture = try #require(ProfilePicture(media)) + + #expect(picture.thumbnailBlurhash == nil) + } + + /// A row written by a build that predates the field must still decode — the + /// missing key maps to nil, not a throw. + @Test("Decodes a JSON blob written before the BlurHash field existed") + func decodesLegacyJSONWithoutBlurhash() throws { + let picture = ProfilePicture( + blobID: Self.blobID(1), + thumbnailBlobID: Self.blobID(2), + thumbnailBlurhash: "abc" + ) + + // Strip the field to simulate a row from an earlier schema. + let encoded = try JSONEncoder().encode(picture) + var object = try #require( + try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + object.removeValue(forKey: "thumbnailBlurhash") + let stripped = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(ProfilePicture.self, from: stripped) + + #expect(decoded.thumbnailBlobID == Self.blobID(2)) + #expect(decoded.thumbnailBlurhash == nil) + } + // MARK: - Fixtures - private static func blobID(_ seed: UInt8) -> BlobID { @@ -93,4 +147,24 @@ struct ProfilePictureTests { } } } + + private static func media( + _ renditions: [(role: Flipcash_Blob_V1_Rendition.Role, id: UInt8, width: UInt32, blurhash: String)] + ) -> Flipcash_Blob_V1_Media { + Flipcash_Blob_V1_Media.with { media in + media.renditions = renditions.map { rendition in + Flipcash_Blob_V1_Rendition.with { + $0.role = rendition.role + $0.blobID = .with { $0.value = blobID(rendition.id).data } + $0.blob = .with { metadata in + metadata.mimeType = "image/jpeg" + metadata.image = .with { + $0.width = rendition.width + $0.blurhash = rendition.blurhash + } + } + } + } + } + } } diff --git a/FlipcashTests/BlurHashTests.swift b/FlipcashTests/BlurHashTests.swift new file mode 100644 index 00000000..a7180992 --- /dev/null +++ b/FlipcashTests/BlurHashTests.swift @@ -0,0 +1,56 @@ +// +// BlurHashTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashUI + +@Suite("BlurHash Tests") +struct BlurHashTests { + + /// A valid 4×3-component hash from the BlurHash reference test vectors. + private let validHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + + @Test("Decodes a valid hash to an image of the requested size") + func decodesValidHash() throws { + let image = try #require(BlurHash.decode(validHash, width: 32, height: 24)) + #expect(image.size == CGSize(width: 32, height: 24)) + } + + @Test("Returns nil for nil or blank hashes") + func nilForNilOrBlank() { + #expect(BlurHash.decode(nil, width: 16, height: 16) == nil) + #expect(BlurHash.decode("", width: 16, height: 16) == nil) + } + + @Test("Returns nil when the declared component count doesn't match the length") + func nilForComponentMismatch() { + // Truncated hash: the size flag promises more components than the string carries. + let truncated = String(validHash.dropLast(4)) + #expect(BlurHash.decode(truncated, width: 16, height: 16) == nil) + } + + @Test("Returns nil for non-positive dimensions") + func nilForNonPositiveDimensions() { + #expect(BlurHash.decode(validHash, width: 0, height: 16) == nil) + #expect(BlurHash.decode(validHash, width: 16, height: -1) == nil) + } + + @Test("Returns nil for characters outside the base-83 alphabet") + func nilForOutOfAlphabetCharacters() { + // 'é' is not part of the BlurHash alphabet. + let invalid = "L" + String(repeating: "é", count: validHash.count - 1) + #expect(BlurHash.decode(invalid, width: 16, height: 16) == nil) + } + + @Test("Cache returns nil for a nil or empty hash and decodes otherwise") + func cacheHandlesAbsentAndValidHashes() throws { + #expect(BlurHashCache.shared.image(for: nil) == nil) + #expect(BlurHashCache.shared.image(for: "") == nil) + + let image = try #require(BlurHashCache.shared.image(for: validHash, width: 24, height: 24)) + #expect(image.size == CGSize(width: 24, height: 24)) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift index bc0e0ea8..4df74b44 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatProfileCardCell.swift @@ -39,6 +39,7 @@ private struct ProfileCardView: View { id: card.avatarID, displayName: card.name, imageData: card.imageData, + blurhash: card.blurhash, size: 80 ) .accessibilityHidden(true) diff --git a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift new file mode 100644 index 00000000..2c1a259a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift @@ -0,0 +1,181 @@ +// +// BlurHash.swift +// FlipcashUI +// + +import UIKit + +/// Decoder for [BlurHash](https://blurha.sh) strings — the compact, blurred image preview that +/// ships in a media item's `ImageMetadata`. Decode it into a tiny image and let the image layer +/// scale it up as an instant placeholder while the real (larger) image downloads. +/// +/// Ported from the public-domain reference implementation. A hash only encodes a handful of DCT +/// components, so decode at a low resolution (a couple dozen pixels) — anything larger just wastes +/// cycles for no visible gain. +nonisolated public enum BlurHash { + + /// Decodes `hash` into a `width`×`height` image, or nil when the string is malformed. `punch` + /// adjusts contrast (1 = as encoded). + public static func decode(_ hash: String?, width: Int, height: Int, punch: Float = 1) -> UIImage? { + guard let hash, hash.count >= 6, width > 0, height > 0 else { return nil } + + let chars = Array(hash) + + guard let sizeFlag = decode83(chars, 0, 1) else { return nil } + let numCompX = sizeFlag % 9 + 1 + let numCompY = sizeFlag / 9 + 1 + guard chars.count == 4 + 2 * numCompX * numCompY else { return nil } + + guard let quantisedMaxAc = decode83(chars, 1, 2) else { return nil } + let maxAc = Float(quantisedMaxAc + 1) / 166 + + var colors = [SIMD3](repeating: .zero, count: numCompX * numCompY) + for i in 0.. Int? { + var result = 0 + for i in from.. SIMD3 { + SIMD3( + srgbToLinear(colorEnc >> 16 & 255), + srgbToLinear(colorEnc >> 8 & 255), + srgbToLinear(colorEnc & 255) + ) + } + + private static func decodeAc(_ value: Int, _ maxAc: Float) -> SIMD3 { + SIMD3( + signPow(Float(value / (19 * 19) - 9) / 9) * maxAc, + signPow(Float(value / 19 % 19 - 9) / 9) * maxAc, + signPow(Float(value % 19 - 9) / 9) * maxAc + ) + } + + /// sign(value) * value² — the reference impl's quantisation curve. + private static func signPow(_ value: Float) -> Float { + let squared = value * value + return value < 0 ? -squared : squared + } + + private static func srgbToLinear(_ colorEnc: Int) -> Float { + let v = Float(colorEnc) / 255 + return v <= 0.04045 ? v / 12.92 : powf((v + 0.055) / 1.055, 2.4) + } + + private static func linearToSrgb(_ value: Float) -> Int { + let v = min(max(value, 0), 1) + let srgb = v <= 0.0031308 ? v * 12.92 : 1.055 * powf(v, 1 / 2.4) - 0.055 + return Int(srgb * 255 + 0.5) + } + + private static func composeImage( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: [SIMD3] + ) -> UIImage? { + // Precompute the cosine basis for each axis so the inner pixel loop is just multiplies. + var cosX = [Float](repeating: 0, count: width * numCompX) + for x in 0...zero + for j in 0..() + + private init() { + cache.countLimit = 200 + } + + /// Returns the decoded preview for `hash` at `width`×`height`, decoding and caching on a miss. + /// Returns nil when the hash is absent or malformed. + public func image(for hash: String?, width: Int = 24, height: Int = 24) -> UIImage? { + guard let hash, !hash.isEmpty else { return nil } + + let key = "\(hash)@\(width)x\(height)" as NSString + if let cached = cache.object(forKey: key) { + return cached + } + guard let image = BlurHash.decode(hash, width: width, height: height) else { return nil } + cache.setObject(image, forKey: key) + return image + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index 3a232966..ecf25f2c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -16,12 +16,14 @@ public struct ContactAvatarView: View { public let id: String public let displayName: String public let imageData: Data? + public let blurhash: String? public let size: CGFloat - public init(id: String, displayName: String, imageData: Data? = nil, size: CGFloat = 44) { + public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44) { self.id = id self.displayName = displayName self.imageData = imageData + self.blurhash = blurhash self.size = size } @@ -32,6 +34,12 @@ public struct ContactAvatarView: View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fill) + } else if let preview = BlurHashCache.shared.image(for: blurhash) { + // The picture is loading: show its BlurHash as an instant blurred + // preview until the bytes arrive. + Image(uiImage: preview) + .resizable() + .aspectRatio(contentMode: .fill) } else { LinearGradient.avatarPlaceholder .overlay {