Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
}
Expand Down Expand Up @@ -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]
)
}

Expand Down
7 changes: 5 additions & 2 deletions Flipcash/Core/Controllers/Database/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@ nonisolated struct ConversationMemberTable: Sendable {
let readPointer = Expression <UInt64?> ("readPointer")
let readPointerTimestamp = Expression <Double?> ("readPointerTimestamp")
// Profile-picture rendition blob ids, when the member has a picture.
let profilePictureBlobID = Expression <Data?> ("profilePictureBlobID")
let profilePictureThumbnailBlobID = Expression <Data?> ("profilePictureThumbnailBlobID")
let profilePictureBlobID = Expression <Data?> ("profilePictureBlobID")
let profilePictureThumbnailBlobID = Expression <Data?> ("profilePictureThumbnailBlobID")
// The thumbnail rendition's BlurHash preview, when present.
let profilePictureThumbnailBlurhash = Expression <String?> ("profilePictureThumbnailBlurhash")
}

// One row per message; cash content is decomposed across the amount columns
Expand Down Expand Up @@ -406,6 +408,7 @@ nonisolated extension Database {
t.column(conversationMemberTable.readPointerTimestamp)
t.column(conversationMemberTable.profilePictureBlobID)
t.column(conversationMemberTable.profilePictureThumbnailBlobID)
t.column(conversationMemberTable.profilePictureThumbnailBlurhash)
})
}

Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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)?

Expand All @@ -465,6 +468,7 @@ private struct ConversationTitleItem: View {
contact: contact,
conversationID: conversationID,
imageData: imageData,
blurhash: blurhash,
width: width
)
if let onTap {
Expand All @@ -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 {
Expand All @@ -492,6 +497,7 @@ private struct ConversationTitleLabel: View {
id: contact?.contactId ?? conversationID?.description ?? title,
displayName: title,
imageData: imageData,
blurhash: blurhash,
size: 44
)
.accessibilityHidden(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Flipcash/Core/Screens/Send/RecipientPickerScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ struct RecipientRowScaffold<Trailing: View>: View {
let title: String
let subtitle: String?
let imageData: Data?
var blurhash: String? = nil
var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn
let accessibilityLabel: String
let onTap: () -> Void
Expand All @@ -404,6 +405,7 @@ struct RecipientRowScaffold<Trailing: View>: View {
title: title,
subtitle: subtitle,
imageData: imageData,
blurhash: blurhash,
accessoryPlacement: accessoryPlacement
) {
trailing
Expand All @@ -422,6 +424,7 @@ struct RecipientRowBody<Trailing: View>: View {
let title: String
let subtitle: String?
let imageData: Data?
var blurhash: String? = nil
var accessoryPlacement: RecipientRowAccessoryPlacement = .trailingColumn
@ViewBuilder let trailing: Trailing

Expand All @@ -431,6 +434,7 @@ struct RecipientRowBody<Trailing: View>: View {
id: avatarID,
displayName: title,
imageData: imageData,
blurhash: blurhash,
size: 44
)
VStack(alignment: .leading, spacing: 2) {
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Supporting Files/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<string>com.flipcash.app.openChat</string>
</array>
<key>SQLiteVersion</key>
<integer>25</integer>
<integer>26</integer>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
23 changes: 18 additions & 5 deletions FlipcashCore/Sources/FlipcashCore/Models/ProfilePicture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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)
)
}
}
Expand All @@ -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
)
}
}
Expand All @@ -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
}
}
74 changes: 74 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/ProfilePictureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
}
}
}
}
}
56 changes: 56 additions & 0 deletions FlipcashTests/BlurHashTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ private struct ProfileCardView: View {
id: card.avatarID,
displayName: card.name,
imageData: card.imageData,
blurhash: card.blurhash,
size: 80
)
.accessibilityHidden(true)
Expand Down
Loading