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
115 changes: 115 additions & 0 deletions Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// ProfileAvatarStore.swift
// Flipcash
//

import Foundation
import FlipcashCore

private let logger = Logger(label: "flipcash.profile-avatars")

/// Other users' profile-picture thumbnails, as raw bytes so every surface renders them through the
/// same `ContactAvatarView(imageData:)` path address-book avatars use.
///
/// Backed by ``BlobCache``, so bytes fetched in one launch are on screen at first paint in
/// the next. The in-memory map is what `@Observable` watches — the disk cache alone cannot invalidate
/// a view — and it is populated from disk without a round trip.
@Observable
final class ProfileAvatarStore {

private var dataByUser: [UserID: Data] = [:]

/// Which blob each cached entry came from. A user who changes their picture keeps their user id
/// and gets a new blob id, so this is what tells a stale entry from a current one.
@ObservationIgnored private var blobByUser: [UserID: BlobID] = [:]

@ObservationIgnored private var inFlight: Set<UserID> = []
@ObservationIgnored private let cache: BlobCache
@ObservationIgnored private let mintURL: (BlobID, UserID) async throws -> URL?
@ObservationIgnored private let fetch: (URL) async throws -> Data

/// - Parameters:
/// - mintURL: mints a download URL for a blob. Injected because `FlipClient` is a concrete
/// class with a live gRPC channel, so there is no fake to pass a test.
/// - fetch: downloads the bytes at a URL.
init(
cache: BlobCache = .profilePictures,
mintURL: @escaping (BlobID, UserID) async throws -> URL?,
fetch: @escaping (URL) async throws -> Data = { url in
try await URLSession.shared.data(from: url).0
}
) {
self.cache = cache
self.mintURL = mintURL
self.fetch = fetch
}

convenience init(flipClient: FlipClient, owner: KeyPair, cache: BlobCache = .profilePictures) {
self.init(
cache: cache,
mintURL: { blobID, userID in
try await flipClient.blobDownloadURL(
blobID: blobID,
owner: owner,
accessContext: .profile(userID)
)
}
)
}

/// The cached thumbnail bytes for a user, or nil until ``load(userID:picture:)`` has them.
func data(for userID: UserID?) -> Data? {
userID.flatMap { dataByUser[$0] }
}

/// Makes a member's current thumbnail available to ``data(for:)``.
///
/// Returns without a round trip when the bytes are already in memory or on disk for that exact
/// blob. Download URLs expire, so one is minted per fetch and never stored.
func load(userID: UserID?, picture: ProfilePicture?) async {
guard let userID, let blobID = picture?.thumbnailBlobID else { return }

// A different blob under the same user id is a changed picture, not a cache hit.
if blobByUser[userID] == blobID, dataByUser[userID] != nil { return }
guard !inFlight.contains(userID) else { return }

if let cached = cache.data(for: blobID) {
store(cached, userID: userID, blobID: blobID)
return
}

inFlight.insert(userID)
defer { inFlight.remove(userID) }

do {
guard let url = try await mintURL(blobID, userID) else { return }

let data = try await fetch(url)
cache.write(data, for: blobID)
store(data, userID: userID, blobID: blobID)
} catch {
guard !Task.isCancelled else { return }
// The surfaces render the monogram placeholder without it.
logger.info("Failed to load a profile avatar", metadata: [
"userId": "\(userID)",
"error": "\(error)",
])
}
}

/// Warms several members' thumbnails at once, for a list that is about to draw all of them —
/// including the rows below the fold, so scrolling never lands on a blurhash.
///
/// Returns immediately. Each fetch lands in the store on its own and invalidates only the rows
/// that read it; a caller that awaited the set would hold the whole list behind the slowest one.
func preload(_ members: [(userID: UserID?, picture: ProfilePicture?)]) {
for member in members {
Task { await load(userID: member.userID, picture: member.picture) }
}
}

private func store(_ data: Data, userID: UserID, blobID: BlobID) {
dataByUser[userID] = data
blobByUser[userID] = blobID
}
}
16 changes: 8 additions & 8 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ struct ConversationScreen: View {
title: title,
contact: contact,
conversationID: conversationID,
imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID),
imageData: contact?.imageData ?? sessionContainer.profileAvatars.data(for: tipCounterpart?.userID),
blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash,
width: max(navBarWidth - Self.titleSideInset * 2, 0),
onTap: titleTapAction,
Expand All @@ -270,7 +270,7 @@ struct ConversationScreen: View {
}
// Fetch the tip counterpart's avatar for the title and profile card.
.task(id: tipCounterpart?.userID) {
await sessionContainer.tipAvatars.load(
await sessionContainer.profileAvatars.load(
userID: tipCounterpart?.userID,
picture: tipCounterpart?.profilePicture
)
Expand Down Expand Up @@ -530,15 +530,15 @@ struct ConversationScreen: View {
conversationID: id,
controller: conversationController,
session: session,
// `tipAvatars` is captured directly so the coordinator retains
// `profileAvatars` is captured directly so the coordinator retains
// one small store, not the whole session container.
profileCard: { [context, contactSyncController, conversationController, session, counterpartUserID, tipAvatars = sessionContainer.tipAvatars] in
profileCard: { [context, contactSyncController, conversationController, session, counterpartUserID, profileAvatars = sessionContainer.profileAvatars] in
Self.profileCard(
context: context,
conversationID: id,
directory: contactSyncController.resolvedContacts.onFlipcash,
controller: conversationController,
tipAvatars: tipAvatars,
profileAvatars: profileAvatars,
// Resolved inside the closure, not captured: the card
// must pick up the conversation the first tip creates.
fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session)
Expand Down Expand Up @@ -586,7 +586,7 @@ struct ConversationScreen: View {
conversationID: ConversationID,
directory: [ResolvedContact],
controller: ConversationController,
tipAvatars: TipAvatarStore,
profileAvatars: ProfileAvatarStore,
fallbackCounterpart: ConversationMember? = nil
) -> ChatProfileCard {
if let conversation = controller.conversation(withID: conversationID),
Expand All @@ -595,7 +595,7 @@ struct ConversationScreen: View {
return ChatProfileCard(
name: controller.displayName(for: conversation),
avatarID: counterpart?.userID?.uuidString ?? conversationID.description,
imageData: tipAvatars.data(for: counterpart?.userID),
imageData: profileAvatars.data(for: counterpart?.userID),
blurhash: counterpart?.profilePicture?.thumbnailBlurhash,
counterpart: Self.tipDMCounterpart(counterpart)
)
Expand All @@ -605,7 +605,7 @@ struct ConversationScreen: View {
return ChatProfileCard(
name: counterpart.displayName,
avatarID: counterpart.userID?.uuidString ?? conversationID.description,
imageData: tipAvatars.data(for: counterpart.userID),
imageData: profileAvatars.data(for: counterpart.userID),
blurhash: counterpart.profilePicture?.thumbnailBlurhash,
counterpart: Self.tipDMCounterpart(counterpart)
)
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/Home/ActivityAvatar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ final class ActivityResolution {

avatarBlurhash = picture?.thumbnailBlurhash
guard let picture else { return }
await sessionContainer.tipAvatars.load(userID: userID, picture: picture)
avatarData = sessionContainer.tipAvatars.data(for: userID)
await sessionContainer.profileAvatars.load(userID: userID, picture: picture)
avatarData = sessionContainer.profileAvatars.data(for: userID)
}

private func resolveMintMetadata(_ mint: PublicKey, session: Session) async -> StoredMintMetadata? {
Expand Down
17 changes: 10 additions & 7 deletions Flipcash/Core/Screens/Main/Profile/ProfilePictureLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ enum ProfilePictureLoader {
static func thumbnail(
for picture: ProfilePicture?,
using client: FlipClient,
owner: KeyPair
owner: KeyPair,
cache: BlobCache = .profilePictures
) async -> UIImage? {
guard let blobID = picture?.thumbnailBlobID else { return nil }

// Answered from the cache before the round trip below, so a relaunch draws
// the picture at first paint instead of after a download URL comes back.
// Safe to trust: a blob is immutable, so a new picture is a new key.
if let cached = await RemoteImageLoader.cachedImage(cacheKey: blobID.description) {
return cached
// Answered from disk before the round trip below, so a relaunch draws the
// picture at first paint instead of after a download URL comes back. Safe
// to trust: a blob is immutable, so a new picture is a new key.
if let cached = cache.data(for: blobID), let image = UIImage(data: cached) {
return image
}

do {
Expand All @@ -39,7 +40,9 @@ enum ProfilePictureLoader {
return nil
}

return try await RemoteImageLoader.image(at: url, cacheKey: blobID.description)
let (data, _) = try await URLSession.shared.data(from: url)
cache.write(data, for: blobID)
return UIImage(data: data)
} catch {
guard !Task.isCancelled else { return nil }
logger.info("Failed to load a profile picture", metadata: ["error": "\(error)"])
Expand Down
64 changes: 0 additions & 64 deletions Flipcash/Core/Screens/Main/Tips/TipAvatarStore.swift

This file was deleted.

16 changes: 14 additions & 2 deletions Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import FlipcashUI
struct TipConversationsScreen: View {

@Environment(ConversationController.self) private var conversationController
@Environment(SessionContainer.self) private var sessionContainer
@Environment(AppRouter.self) private var router

var body: some View {
Expand Down Expand Up @@ -41,6 +42,17 @@ struct TipConversationsScreen: View {
NewChatButton()
}
}
// Every counterpart, not just the rows on screen. A row's own `.task` fires when the row is
// built, which in a `List` is when it scrolls into view — so without this the avatar below
// the fold starts downloading at the moment the user is looking at its blurhash.
.task(id: conversations.count) {
sessionContainer.profileAvatars.preload(
conversations.map {
let counterpart = $0.counterpart(excluding: conversationController.selfUserID)
return (counterpart?.userID, counterpart?.profilePicture)
}
)
}
}
}

Expand Down Expand Up @@ -131,7 +143,7 @@ private struct TipConversationRow: View {
avatarID: counterpart?.userID?.uuidString ?? conversation.id.description,
title: title,
subtitle: subtitle,
imageData: sessionContainer.tipAvatars.data(for: counterpart?.userID),
imageData: sessionContainer.profileAvatars.data(for: counterpart?.userID),
blurhash: counterpart?.profilePicture?.thumbnailBlurhash,
accessoryPlacement: .titleLine,
accessibilityLabel: hasUnread ? "\(title), unread messages" : title,
Expand All @@ -144,7 +156,7 @@ private struct TipConversationRow: View {
)
}
.task(id: counterpart?.userID) {
await sessionContainer.tipAvatars.load(
await sessionContainer.profileAvatars.load(
userID: counterpart?.userID,
picture: counterpart?.profilePicture
)
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Main/Tips/TipFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,12 @@ final class TipFlow {
}
}

/// Fetches the recipient's avatar through the shared tip-avatar store —
/// Fetches the recipient's avatar through the shared profile-avatar store —
/// warming the same cache the conversation surfaces read — and re-renders
/// the card with it. The card is already up, so a failure just leaves the
/// placeholder.
private func loadAvatar(for recipient: TipRecipient, picture: ProfilePicture?) async {
let store = sessionContainer.tipAvatars
let store = sessionContainer.profileAvatars
await store.load(userID: recipient.userID, picture: picture)
guard let data = store.data(for: recipient.userID),
let avatar = UIImage(data: data),
Expand Down
Loading
Loading