feat(cache): cache profile metadata and follower/following counts

Profile screen now reads initial values from cache so navigating back
shows data instantly instead of re-fetching everything from scratch.

- Seed displayName/about/picture from User.metadataOrNull() on compose
- Add followerCounts/followingCounts maps to DesktopLocalCache
- Write counts on each update, read on profile open
- Follower count still ticks up live (good UX) but starts from cached value
- 4 new tests for profile count caching + metadata cache reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-23 14:28:40 +02:00
parent 3e41bab5d5
commit f2169e0236
3 changed files with 112 additions and 9 deletions
@@ -491,6 +491,29 @@ class DesktopLocalCache : ICacheProvider {
eventStream.emitDeletedNotes(notes) eventStream.emitDeletedNotes(notes)
} }
// ----- Profile count cache -----
private val followerCounts = ConcurrentHashMap<HexKey, Int>()
private val followingCounts = ConcurrentHashMap<HexKey, Int>()
fun getCachedFollowerCount(pubkey: HexKey): Int = followerCounts[pubkey] ?: 0
fun getCachedFollowingCount(pubkey: HexKey): Int = followingCounts[pubkey] ?: 0
fun cacheFollowerCount(
pubkey: HexKey,
count: Int,
) {
followerCounts[pubkey] = count
}
fun cacheFollowingCount(
pubkey: HexKey,
count: Int,
) {
followingCounts[pubkey] = count
}
// ----- Stats ----- // ----- Stats -----
fun userCount(): Int = users.size() fun userCount(): Int = users.size()
@@ -503,6 +526,8 @@ class DesktopLocalCache : ICacheProvider {
addressableNotes.clear() addressableNotes.clear()
deletedEvents.clear() deletedEvents.clear()
_followedUsers.value = emptySet() _followedUsers.value = emptySet()
followerCounts.clear()
followingCounts.clear()
} }
} }
@@ -127,12 +127,22 @@ fun UserProfileScreen(
val relayStatuses by relayManager.relayStatuses.collectAsState() val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays = remember(relayStatuses) { relayStatuses.keys } val connectedRelays = remember(relayStatuses) { relayStatuses.keys }
// User metadata // User metadata — seed from cache so returning to profile is instant
var displayName by remember { mutableStateOf<String?>(null) } val cachedUser = remember(pubKeyHex) { localCache.getUserIfExists(pubKeyHex) }
var about by remember { mutableStateOf<String?>(null) } val cachedMetadata = remember(pubKeyHex) { cachedUser?.metadataOrNull() }
var picture by remember { mutableStateOf<String?>(null) } var displayName by remember { mutableStateOf(cachedMetadata?.bestName()) }
var followersCount by remember { mutableStateOf(0) } var about by remember {
var followingCount by remember { mutableStateOf(0) } mutableStateOf(
cachedMetadata
?.flow
?.value
?.info
?.about,
)
}
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
var followersCount by remember { mutableStateOf(localCache.getCachedFollowerCount(pubKeyHex)) }
var followingCount by remember { mutableStateOf(localCache.getCachedFollowingCount(pubKeyHex)) }
// Profile editing state (only for own profile) // Profile editing state (only for own profile)
val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex
@@ -279,8 +289,9 @@ fun UserProfileScreen(
pubKeyHex = pubKeyHex, pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ContactListEvent) { if (event is ContactListEvent) {
// Count the number of people this user follows val count = event.verifiedFollowKeySet().size
followingCount = event.verifiedFollowKeySet().size followingCount = count
localCache.cacheFollowingCount(pubKeyHex, count)
} }
}, },
onEose = { _, _ -> }, onEose = { _, _ -> },
@@ -314,7 +325,9 @@ fun UserProfileScreen(
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Count unique authors who follow this user // Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) { if (followerAuthors.add(event.pubKey)) {
followersCount = followerAuthors.size val count = followerAuthors.size
followersCount = count
localCache.cacheFollowerCount(pubKeyHex, count)
} }
}, },
onEose = { _, _ -> }, onEose = { _, _ -> },
@@ -568,4 +568,69 @@ class DesktopCachePipelineTest {
assertEquals(1, filtered.size, "applyFilter should only include followed users") assertEquals(1, filtered.size, "applyFilter should only include followed users")
assertEquals(followedPubKey, filtered.first().author?.pubkeyHex) assertEquals(followedPubKey, filtered.first().author?.pubkeyHex)
} }
// -----------------------------------------------------------------------
// 10. Profile count caching
// -----------------------------------------------------------------------
@Test
fun `profile follower count is cached and survives clear of note cache`() {
val cache = DesktopLocalCache()
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
cache.cacheFollowerCount(userPubKey, 42)
assertEquals(42, cache.getCachedFollowerCount(userPubKey))
// Updating again overwrites
cache.cacheFollowerCount(userPubKey, 100)
assertEquals(100, cache.getCachedFollowerCount(userPubKey))
}
@Test
fun `profile following count is cached`() {
val cache = DesktopLocalCache()
cache.cacheFollowingCount(userPubKey, 150)
assertEquals(150, cache.getCachedFollowingCount(userPubKey))
}
@Test
fun `clear resets profile count caches`() {
val cache = DesktopLocalCache()
cache.cacheFollowerCount(userPubKey, 42)
cache.cacheFollowingCount(userPubKey, 150)
cache.clear()
assertEquals(0, cache.getCachedFollowerCount(userPubKey))
assertEquals(0, cache.getCachedFollowingCount(userPubKey))
}
@Test
fun `metadata is available from cache after consumption`() {
val cache = DesktopLocalCache()
val metadata =
com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent(
id = "meta1".padEnd(64, '0'),
pubKey = userPubKey,
createdAt = System.currentTimeMillis() / 1000,
tags = emptyArray(),
content = """{"name":"TestUser","display_name":"Test User","about":"A test user"}""",
sig = dummySig,
)
cache.consume(metadata, relayUrl)
val user = cache.getUserIfExists(userPubKey)!!
val cached = user.metadataOrNull()
assertTrue(cached != null, "Metadata should be cached after consumption")
assertEquals("Test User", cached.bestName())
assertEquals(
"A test user",
cached.flow.value
?.info
?.about,
)
}
} }