Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
d18939e
fix: stop public pubky reads queueing behind the sdk lock
Jasonvdb Sep 30, 2026
aa058ba
fix: keep pubky ring row profiles and reuse them on adopt
Jasonvdb Sep 30, 2026
573d235
fix: show the cached profile header while the profile loads
Jasonvdb Sep 30, 2026
7fba72e
fix: stop retrying contact profile lookups that cannot succeed
Jasonvdb Sep 30, 2026
11fff39
fix: keep public reads off the sdk lock after sign-out and drop stale…
Jasonvdb Sep 30, 2026
60e27db
fix: keep loaded contacts when a contacts reload is cancelled
Jasonvdb Sep 30, 2026
3123d7f
fix: keep the tapped ring row lookup and refresh a reused adopt profile
Jasonvdb Sep 30, 2026
a3c8d77
test: add pubky profile loading journeys
Jasonvdb Sep 30, 2026
df8b8b0
docs(changelog): add fragment for pubky profile loading fix
Jasonvdb Sep 30, 2026
3cacc64
Merge master into claude/flow-vibe-pubky-profile-load-lag
Jasonvdb Sep 30, 2026
e2d3a4a
fix: keep the ring choice screen during adoption and reuse the in-fli…
Jasonvdb Sep 30, 2026
44b3460
fix: gate disk-promoted avatars on the cache generation and retry con…
Jasonvdb Sep 30, 2026
bc65361
fix: start profile setup when a reused Ring profile is gone and mirro…
Jasonvdb Sep 30, 2026
3b56e1f
fix: show which ring rows are still looking up their profile
Jasonvdb Oct 1, 2026
f5250e8
fix: wait for the signing identity's running republish before approvi…
Jasonvdb Oct 1, 2026
ad6b5d5
fix: finish the contact payments enable after leaving General Setting…
Jasonvdb Oct 1, 2026
860e5b6
docs: drop Android claims from the cached profile header journey
Jasonvdb Oct 1, 2026
d741592
Merge remote-tracking branch 'origin/master' into claude/flow-vibe-pu…
Jasonvdb Oct 1, 2026
7b98b01
fix: keep public read slots free for what the user is looking at
Jasonvdb Oct 1, 2026
c7e93b7
fix: discover contact receivers without holding the Paykit SDK lock
Jasonvdb Oct 1, 2026
887825b
fix: import contacts with the profiles found while preparing the import
Jasonvdb Oct 1, 2026
2ec1817
fix: show saved contacts before their profiles finish loading
Jasonvdb Oct 1, 2026
c9ba6d4
fix: disable the import buttons while a contact import runs
Jasonvdb Oct 1, 2026
26d21e1
docs: add contact import and contacts list loading journeys
Jasonvdb Oct 1, 2026
178d22b
docs: note the contact import journeys started on iOS
Jasonvdb Oct 1, 2026
d8d5ee7
fix: check a contact's payment request support on the interactive rea…
Jasonvdb Oct 1, 2026
41b82ef
fix: stay where the user went when a contact import finishes after th…
Jasonvdb Oct 1, 2026
bb82654
fix: keep loading contact profiles when a Contacts reload publishes n…
Jasonvdb Oct 1, 2026
9fe0fef
fix: load a label-only contact's profile before editing it
Jasonvdb Oct 1, 2026
b8ad8b1
docs: list the contact import identifiers Android does not share
Jasonvdb Oct 1, 2026
4f86478
refactor: share the contact name order and derive the import flag fro…
Jasonvdb Oct 1, 2026
e4047a2
refactor: copy a profile with new tags through PubkyProfile
Jasonvdb Oct 1, 2026
78602a3
fix: look follows up on the interactive read lane while preparing an …
Jasonvdb Oct 1, 2026
a2fee4d
docs: describe the contact import journeys as shared with Android
Jasonvdb Oct 1, 2026
e6c9c7a
docs: drop the cached profile header from the Android naming differences
Jasonvdb Oct 1, 2026
d381540
docs: note how Android journeys find the Ring rows and import overview
Jasonvdb Oct 1, 2026
f6ee125
Merge remote-tracking branch 'origin/master' into claude/flow-vibe-pu…
Jasonvdb Oct 1, 2026
4e5e7ef
fix: remember the profiles a contact import saves for the Contacts list
Jasonvdb Oct 1, 2026
16611f7
fix: stay where the user went when a contact import finishes after th…
Jasonvdb Oct 1, 2026
99b9ecb
fix: reject public pubky reads that race a wallet wipe
Jasonvdb Oct 1, 2026
772ed13
docs: narrow the contact import journey to an import you leave
Jasonvdb Oct 1, 2026
589bcdb
docs(changelog): leave the contact import speed-up to #851's fragment
Jasonvdb Oct 1, 2026
7988037
test: drop the generic parameter from the public read wipe assertion
Jasonvdb Oct 1, 2026
c13b34e
fix: stop a contact import that a sign-out overtakes
Jasonvdb Oct 1, 2026
751e02e
refactor: drop the unused read lane from contact receiver discovery
Jasonvdb Oct 1, 2026
427f1d5
test: check that an import finishing after you leave stays where you …
Jasonvdb Oct 1, 2026
54e13d0
docs: name only the contact import identifiers the journeys use
Jasonvdb Oct 1, 2026
a34755c
fix: wait for a contact's background profile lookup before saving an …
Jasonvdb Oct 1, 2026
4f63883
fix: keep Create Profile open when contact discovery finishes after it
Jasonvdb Oct 1, 2026
e6f2392
fix: let a screen's contact lookup take over its background lookup
Jasonvdb Oct 1, 2026
408a7ba
docs: say which refresh a screen's contact lookup takes the contact from
Jasonvdb Oct 1, 2026
1c849ce
test: check that a screen's contact lookup takes the contact over as …
Jasonvdb Oct 1, 2026
7ddd157
test: merge near-duplicate contact lookup and load tests into case ta…
Jasonvdb Oct 1, 2026
11d9f76
test: check every bulk public read kind in one lock and read slot test
Jasonvdb Oct 1, 2026
88bac3a
test: check both cancelled queued reads in one read limiter test
Jasonvdb Oct 1, 2026
c51004f
test: table the mirrored Ring row profiles and the row spinner checks
Jasonvdb Oct 1, 2026
5e5e18a
test: merge Ring adoption and sign-out tests that differ only by case
Jasonvdb Oct 1, 2026
8ef37a4
test: restore checks the merged case tables lost and name each failin…
Jasonvdb Oct 1, 2026
f677814
refactor: look the Pubky Ring rows up from .task on the choice screen
Jasonvdb Oct 2, 2026
9a5de71
refactor: queue contact tag saves in ContactsManager
Jasonvdb Oct 2, 2026
5164485
test: check tag changes queued during a contact's lookup keep its pro…
Jasonvdb Oct 2, 2026
17a2b21
fix: give freed public read slots to interactive reads before queued …
Jasonvdb Oct 2, 2026
b3f2eaf
fix: keep a contacts load quiet when its record read fails after canc…
Jasonvdb Oct 2, 2026
ed82ce5
fix: stop a contact payments change when Pubky signs out during its c…
Jasonvdb Oct 2, 2026
ed6b97a
docs: clarify when the current Pubky session is nil or changes
Jasonvdb Oct 2, 2026
93a3e31
fix: stop a contact payments change from publishing or writing state …
Jasonvdb Oct 2, 2026
1450d5c
test: hold only publishing calls when signing out during a contact pa…
Jasonvdb Oct 2, 2026
0a5d47a
fix: stop the Pay Contacts enable when Pubky signs out
Jasonvdb Oct 2, 2026
9d0a526
fix: keep Pay Contacts in place when the enable stops or the user leaves
Jasonvdb Oct 2, 2026
e98f3bd
Merge remote-tracking branch 'origin/master' into claude/flow-vibe-pu…
Jasonvdb Oct 2, 2026
fd3783f
docs: note the Profile loading screen Contacts shows while the sessio…
Jasonvdb Oct 2, 2026
afff078
fix: drop queued tag changes when the Pubky session changes
Jasonvdb Oct 2, 2026
67d54aa
fix: announce saved contact changes only when the saved keys change
Jasonvdb Oct 2, 2026
92f5f20
fix: skip contact lookups for profiles resolved in the last ten minutes
Jasonvdb Oct 2, 2026
56ec0b6
fix: apply contact profile refresh results in batches
Jasonvdb Oct 2, 2026
e2c68bf
fix: remember failed pubky avatar fetches
Jasonvdb Oct 2, 2026
bcb3a92
fix: stop looking up the profiles of removed contacts
Jasonvdb Oct 2, 2026
7186200
fix: bind a contact edit to its session before it waits
Jasonvdb Oct 2, 2026
0444524
fix: ignore a missing-profile refresh that a profile save overtakes
Jasonvdb Oct 2, 2026
7b4adf0
fix: refuse a contact save whose identity changed under the Paykit lock
Jasonvdb Oct 2, 2026
6bb0f2d
fix: bind a profile edit to its session before it saves
Jasonvdb Oct 2, 2026
6d29518
fix: report a profile avatar upload error as a profile error
Jasonvdb Oct 2, 2026
3ced8b1
fix: let the latest contact payments change win over an older one
Jasonvdb Oct 3, 2026
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
2 changes: 1 addition & 1 deletion Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ struct AppScene: View {
paykitPaymentRequestManager.clear()
}
}
.onReceive(contactsManager.$contacts) { contacts in
.onReceive(contactsManager.savedContactsChangedPublisher) { contacts in
guard PaykitFeatureFlags.isUIEnabled,
wallet.walletExists == true,
pubkyProfile.authState == .authenticated
Expand Down
15 changes: 13 additions & 2 deletions Bitkit/Components/PubkyChoiceRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ struct PubkyChoiceRow: View {
let title: String
var avatarName: String?
var avatarImageUrl: String?
var isLoading = false
var isLookingUp = false
var lookupAccessibilityId: String?
let accessibilityId: String
let action: () -> Void

Expand All @@ -26,7 +29,13 @@ struct PubkyChoiceRow: View {

Spacer()

if let avatarName {
if isLookingUp {
ActivityIndicator(size: 20)
.frame(width: 32, height: 32)
.accessibilityElement()
.accessibilityLabel(t("profile__choice_loading_profile"))
.accessibilityIdentifier(lookupAccessibilityId ?? "")
} else if let avatarName {
PubkyContactAvatar(name: avatarName, imageUrl: avatarImageUrl, size: 32)
}
}
Expand All @@ -39,7 +48,9 @@ struct PubkyChoiceRow: View {

private var iconView: some View {
Group {
if let icon {
if isLoading {
ActivityIndicator(size: 20)
} else if let icon {
Image(icon)
.resizable()
.scaledToFit()
Expand Down
133 changes: 117 additions & 16 deletions Bitkit/Components/PubkyImage.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CryptoKit
import ImageIO
import Paykit
import SwiftUI

/// Loads and displays an image from a `pubky://` URI using Paykit's Pubky file fetch.
Expand Down Expand Up @@ -60,28 +61,52 @@ struct PubkyImage: View {
try await Self.loadImageOffMain(uri: uri)
}.value
uiImage = image
} catch PubkyImageError.recentlyFailed {
hasFailed = true
} catch {
Logger.error("Failed to load pubky image: \(error)", context: "PubkyImage")
hasFailed = true
}
}

/// All heavy work (disk cache, network/FFI) runs off the main actor.
private nonisolated static func loadImageOffMain(uri: String) async throws -> UIImage {
if let cached = await PubkyImageCache.shared.image(for: uri) {
/// All heavy work (disk cache, network/FFI) runs off the main actor. A URI whose fetch failed recently fails at once
/// without the network, while a disk cache hit still wins.
nonisolated static func loadImageOffMain(
uri: String,
cache: PubkyImageCache = .shared,
fetchFile: @escaping @Sendable (_ uri: String, _ maxBytes: UInt64) async throws -> Data = {
try await PubkyService.fetchFile(uri: $0, maxBytes: $1)
}
) async throws -> UIImage {
let cacheGeneration = cache.generation
if let cached = await cache.image(for: uri, generation: cacheGeneration) {
return cached
}
guard !cache.hasRecentFailure(for: uri) else {
throw PubkyImageError.recentlyFailed
}

let data = try await PubkyService.fetchFile(uri: uri, maxBytes: PubkyImagePolicy.maxDownloadBytes)
let blobData = try await resolveImageData(data, originalUri: uri)
do {
let data = try await fetchFile(uri, PubkyImagePolicy.maxDownloadBytes)
let blobData = try await resolveImageData(data, originalUri: uri, fetchFile: fetchFile)

let image = try PubkyImageDecoder.image(from: blobData)
let image = try PubkyImageDecoder.image(from: blobData)

PubkyImageCache.shared.store(image, data: blobData, for: uri)
return image
cache.store(image, data: blobData, for: uri, generation: cacheGeneration)
return image
} catch {
if !Task.isCancelled {
cache.rememberFailure(error, for: uri, generation: cacheGeneration)
}
throw error
}
}

private nonisolated static func resolveImageData(_ data: Data, originalUri: String) async throws -> Data {
private nonisolated static func resolveImageData(
_ data: Data,
originalUri: String,
fetchFile: @Sendable (_ uri: String, _ maxBytes: UInt64) async throws -> Data
) async throws -> Data {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let src = json["src"] as? String,
src.hasPrefix("pubky://")
Expand All @@ -97,7 +122,7 @@ struct PubkyImage: View {
}

Logger.debug("File descriptor found, fetching blob from: \(src)", context: "PubkyImage")
return try await PubkyService.fetchFile(uri: src, maxBytes: PubkyImagePolicy.maxDownloadBytes)
return try await fetchFile(src, PubkyImagePolicy.maxDownloadBytes)
}
}

Expand All @@ -106,6 +131,8 @@ enum PubkyImagePolicy {
static let maxPixelSize = 512
static let memoryCacheBytes = 32 * 1024 * 1024
static let diskCacheBytes = 32 * 1024 * 1024
/// How long an image whose fetch failed for a reason that can pass, such as a network error, is not fetched again.
static let transientFailureTTL: TimeInterval = 60
}

enum PubkyImageDecoder {
Expand All @@ -131,6 +158,7 @@ private enum PubkyImageError: LocalizedError {
case decodingFailed(Int)
case fileTooLarge(Int)
case crossUserRedirect
case recentlyFailed

var errorDescription: String? {
switch self {
Expand All @@ -140,12 +168,19 @@ private enum PubkyImageError: LocalizedError {
return "Image blob exceeds the byte limit (\(bytes) bytes)"
case .crossUserRedirect:
return "Image descriptor references a different user's namespace"
case .recentlyFailed:
return "Image fetch failed recently"
}
}
}

/// Two-tier cache (memory + disk) so profile images persist across app launches
/// and multiple PubkyImage views with the same URI don't re-fetch.
///
/// It also remembers failed fetches by URI, so an image that cannot load is not fetched again each time it is shown. A
/// missing file and a file over `PubkyImagePolicy.maxDownloadBytes` are remembered until `clear()`, which sign-out and
/// a switch to another identity run, and any other failure for `PubkyImagePolicy.transientFailureTTL`. For a file
/// descriptor, a failed blob fetch counts as a failure of the descriptor URI.
final class PubkyImageCache: @unchecked Sendable {
static let shared = PubkyImageCache()

Expand All @@ -155,27 +190,37 @@ final class PubkyImageCache: @unchecked Sendable {
var lastAccess: UInt64
}

private struct Failure {
let expiresAt: Date?
}

private var memoryCache: [String: MemoryEntry] = [:]
/// Guarded by `memoryLock`, like the memory cache, so `clear()` empties both at once.
private var failures: [String: Failure] = [:]
private var memoryCost = 0
private var accessSequence: UInt64 = 0
private var clearGeneration: UInt64 = 0
private let memoryLock = NSLock()
private let diskQueue = DispatchQueue(label: "pubky-image-cache-disk", qos: .utility)
private let diskDirectory: URL
private let maxFileBytes: Int
private let maxMemoryBytes: Int
private let maxDiskBytes: Int
private let currentDate: @Sendable () -> Date

init(
diskDirectory: URL? = nil,
maxFileBytes: Int = Int(PubkyImagePolicy.maxDownloadBytes),
memoryCostLimit: Int = PubkyImagePolicy.memoryCacheBytes,
diskByteLimit: Int = PubkyImagePolicy.diskCacheBytes
diskByteLimit: Int = PubkyImagePolicy.diskCacheBytes,
currentDate: @escaping @Sendable () -> Date = { Date() }
) {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
self.diskDirectory = diskDirectory ?? caches.appendingPathComponent("pubky-images", isDirectory: true)
self.maxFileBytes = maxFileBytes
maxMemoryBytes = memoryCostLimit
maxDiskBytes = diskByteLimit
self.currentDate = currentDate
try? FileManager.default.createDirectory(at: self.diskDirectory, withIntermediateDirectories: true)
diskQueue.async { [self] in
trimDiskCache()
Expand All @@ -194,7 +239,8 @@ final class PubkyImageCache: @unchecked Sendable {
}

/// Full lookup (memory + disk). Disk I/O runs on a dedicated queue to avoid blocking cooperative threads.
func image(for uri: String) async -> UIImage? {
/// A disk hit is promoted to memory only while `generation` is still current, as with `store`.
func image(for uri: String, generation: UInt64) async -> UIImage? {
if let memoryHit = memoryImage(for: uri) {
return memoryHit
}
Expand All @@ -219,24 +265,76 @@ final class PubkyImageCache: @unchecked Sendable {
}

try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: path.path)
storeInMemory(diskImage, for: uri)
storeInMemory(diskImage, for: uri, generation: generation)
continuation.resume(returning: diskImage)
}
}
}

func store(_ image: UIImage, data: Data, for uri: String) {
/// Bumped by `clear()`. Capture it before fetching and pass it to `store`, so a fetch that finishes after a sign-out
/// or wipe cannot put the previous identity's image back.
var generation: UInt64 {
memoryLock.lock()
defer { memoryLock.unlock() }
return clearGeneration
}

/// Drops the write, in memory and on disk, when `clear()` ran after `generation` was captured.
func store(_ image: UIImage, data: Data, for uri: String, generation: UInt64) {
guard data.count <= maxFileBytes else { return }

storeInMemory(image, for: uri)
storeInMemory(image, for: uri, generation: generation)

diskQueue.async { [self] in
guard generation == self.generation else { return }
let path = diskPath(for: uri)
try? data.write(to: path, options: .atomic)
trimDiskCache()
}
}

/// True while a failed fetch of `uri` is remembered, so the image shows its failed state without fetching again.
func hasRecentFailure(for uri: String) -> Bool {
memoryLock.lock()
defer { memoryLock.unlock() }
guard let failure = failures[uri] else { return false }
if let expiresAt = failure.expiresAt, currentDate() >= expiresAt {
failures[uri] = nil
return false
}
return true
}

/// Remembers that a fetch of `uri` failed with `error`. Like `store`, it is dropped when `clear()` ran after
/// `generation` was captured, and a cancelled fetch is not remembered.
func rememberFailure(_ error: Error, for uri: String, generation: UInt64) {
guard !(error is CancellationError) else { return }
let expiresAt = Self.isPermanentFailure(error) ? nil : currentDate().addingTimeInterval(PubkyImagePolicy.transientFailureTTL)

memoryLock.lock()
defer { memoryLock.unlock() }
guard generation == clearGeneration else { return }
failures[uri] = Failure(expiresAt: expiresAt)
}

/// A missing file, or one over the download limit, stays that way until the profile points at another image.
private static func isPermanentFailure(_ error: Error) -> Bool {
if case .profileNotFound? = error as? PubkyServiceError {
return true
}
if case .fileTooLarge? = error as? PubkyImageError {
return true
}
switch error as? PaykitError {
case .NotFound?:
return true
case let .Protocol(_, context)?:
return context.contains("exceeds maximum size")
default:
return false
}
}

func clear() async {
clearMemoryCache()

Expand All @@ -251,7 +349,9 @@ final class PubkyImageCache: @unchecked Sendable {

private func clearMemoryCache() {
memoryLock.lock()
clearGeneration &+= 1
Comment thread
Jasonvdb marked this conversation as resolved.
memoryCache.removeAll()
failures.removeAll()
memoryCost = 0
memoryLock.unlock()
}
Expand All @@ -270,12 +370,13 @@ final class PubkyImageCache: @unchecked Sendable {
return cgImage.bytesPerRow * cgImage.height
}

private func storeInMemory(_ image: UIImage, for uri: String) {
private func storeInMemory(_ image: UIImage, for uri: String, generation: UInt64) {
let cost = Self.memoryCost(of: image)

memoryLock.lock()
defer { memoryLock.unlock() }

guard generation == clearGeneration else { return }
if let replaced = memoryCache.removeValue(forKey: uri) {
memoryCost -= replaced.cost
}
Expand Down
Loading
Loading