2 Commits

Author SHA1 Message Date
47196dbea4 chore: bump version 2026-07-15 19:44:52 +07:00
30191170f1 chore: fix some crash and performance issues (#45)
Reviewed-on: #45
2026-07-15 12:44:28 +00:00
12 changed files with 164 additions and 114 deletions

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.2.4" versionName = "0.2.5"
} }
packaging { packaging {
resources { resources {

View File

@@ -695,8 +695,7 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache) val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
.collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -397,7 +397,7 @@ fun ContactListItem(
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = profile?.name ?: "", text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -113,12 +113,7 @@ fun UnsignedEvent.roomId(): Long {
pubkeys.add(this.author()) pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys()) pubkeys.addAll(this.tags().publicKeys())
// Sort and hash the list of public keys return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
} }
fun Timestamp.formatAsTime(): String { fun Timestamp.formatAsTime(): String {

View File

@@ -39,12 +39,11 @@ class MessageManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private val _messageSyncState = MutableStateFlow(MessageSyncState()) private val _messageSyncState = MutableStateFlow(MessageSyncState())
val messageSyncState = _messageSyncState.asStateFlow() val messageSyncState = _messageSyncState.asStateFlow()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
_messageSyncState.update(update) _messageSyncState.update(update)
} }
@@ -73,6 +72,8 @@ class MessageManager(private val nostr: Nostr) {
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "gift-wraps" id = "gift-wraps"
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
} }
@@ -225,6 +226,8 @@ class MessageManager(private val nostr: Nostr) {
// Filter out events without public keys (receivers) // Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() } ?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
} }
@@ -248,6 +251,8 @@ class MessageManager(private val nostr: Nostr) {
connectMsgRelays(event) connectMsgRelays(event)
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e) throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
} }
@@ -327,9 +332,6 @@ class MessageManager(private val nostr: Nostr) {
) )
if (output != null) { if (output != null) {
// Keep track of sent events
sentEvents[output.id] = emptyList()
// Keep track of rumor IDs // Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id rumorMap[id] = output.id
@@ -340,6 +342,8 @@ class MessageManager(private val nostr: Nostr) {
} }
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e) throw IllegalStateException("Failed to send message: ${e.message}", e)
} }

View File

@@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientBuilder
@@ -108,14 +108,6 @@ class Nostr(
relays.connectBootstrapRelays() relays.connectBootstrapRelays()
} }
suspend fun reconnect() {
relays.reconnect()
}
suspend fun disconnect() {
relays.disconnect()
}
suspend fun prune() { suspend fun prune() {
try { try {
client?.database()?.wipe() client?.database()?.wipe()
@@ -182,8 +174,6 @@ class Nostr(
when (notification) { when (notification) {
is ClientNotification.Message -> { is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) { when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> { is RelayMessageEnum.EventMsg -> {
val event = message.event val event = message.event
@@ -237,14 +227,6 @@ class Nostr(
} }
} }
is RelayMessageEnum.Ok -> {
if (messages.sentEvents.containsKey(message.eventId)) {
val currentRelays =
messages.sentEvents[message.eventId] ?: emptyList()
messages.sentEvents[message.eventId] = currentRelays + relayUrl
}
}
else -> { else -> {
/* Ignore other message types */ /* Ignore other message types */
} }

View File

@@ -27,6 +27,7 @@ import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) { class ProfileManager(private val nostr: Nostr) {
@@ -80,6 +81,8 @@ class ProfileManager(private val nostr: Nostr) {
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "user-metadata", closeOn = opts) client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
} }
@@ -92,6 +95,8 @@ class ProfileManager(private val nostr: Nostr) {
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays) client?.sync(filter, relays)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to sync mutual contacts: ${e.message}") println("Failed to sync mutual contacts: ${e.message}")
} }
@@ -174,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return newMetadata return newMetadata
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e) throw IllegalStateException("Failed to update identity: ${e.message}", e)
} }
@@ -186,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first() ?: return null val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content()) Metadata.fromJson(event.content())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}") println("Failed to get latest metadata: ${e.message}")
null null
@@ -208,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get all cache metadata: ${e.message}") println("Failed to get all cache metadata: ${e.message}")
return emptyMap() return emptyMap()
@@ -232,6 +243,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
} }
@@ -247,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
target = SendEventTarget.broadcast(), target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(), ackPolicy = AckPolicy.none(),
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to set contact list: ${e.message}", e) throw IllegalStateException("Failed to set contact list: ${e.message}", e)
} }
@@ -258,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
val bodyString: String = response.body() val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString) return Nip05Profile.fromJson(address, bodyString)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
} }
@@ -269,6 +286,8 @@ class ProfileManager(private val nostr: Nostr) {
val profile = profileFromAddress(httpClient, address) val profile = profileFromAddress(httpClient, address)
return profile.publicKey() return profile.publicKey()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e) throw IllegalStateException("Failed to search address: ${e.message}", e)
} }
@@ -304,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e) throw IllegalStateException("Failed to search nostr: ${e.message}", e)
} }
@@ -323,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return events?.first()?.createdAt() return events?.first()?.createdAt()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get latest activity: ${e.message}", e) throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
} }
@@ -340,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
return pubkeys.contains(pubkey) return pubkeys.contains(pubkey)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }
@@ -361,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return contacts.toSet() return contacts.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }

View File

@@ -130,7 +130,7 @@ class RelayManager(private val nostr: Nostr) {
} }
} }
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) { suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
try { try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer) val event = EventBuilder.relayList(relays).finalizeAsync(signer)

View File

@@ -511,7 +511,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -525,7 +525,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -539,7 +539,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl) relays.remove(relayUrl)
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }

View File

@@ -51,13 +51,16 @@ class ChatRepository(
) )
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } val chatRooms = state
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } val isSyncing = nostr.messages.messageSyncState
.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false) .stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } val isPartialProcessedGiftWrap = state
.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false) .stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init { init {
@@ -82,18 +85,7 @@ class ChatRepository(
// Observe new messages // Observe new messages
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
val roomId = event.roomId() updateRoomState(event)
val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
} }
} }
} }
@@ -194,9 +186,10 @@ class ChatRepository(
content = message, content = message,
subject = room.subject, subject = room.subject,
replies = replies, replies = replies,
onRumorCreated = { event -> onRumorCreated = {
updateRoomList(roomId, event) scope.launch(defaultDispatcher) {
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) } updateRoomState(it, roomId)
}
}, },
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -223,26 +216,32 @@ class ChatRepository(
} }
} }
fun isMessageSent(id: EventId): Boolean { private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val giftWrapId = nostr.messages.rumorMap[id] val currentUser = nostr.signer.getPublicKeyAsync() ?: return
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState -> _state.update { currentState ->
val room = currentState.rooms[roomId] ?: return@update currentState val rooms = currentState.rooms.toMutableMap()
val updatedRoom = room.copy( val existingRoom = rooms[roomId]
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt() if (existingRoom == null) {
// New room discovery
val newRoom = Room.new(event, currentUser, roomId)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = event.content(),
createdAt = event.createdAt()
) )
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom)) } else {
// Don't update the room list state for older messages
return@update currentState
} }
currentState.copy(rooms = rooms)
}
// Notify subscribers about the new event (for the active chat screen)
_newEvents.tryEmit(event)
} }
fun resetInternalState() { fun resetInternalState() {

View File

@@ -13,7 +13,6 @@ import su.reya.coop.repository.AccountState
class AccountViewModel( class AccountViewModel(
private val repository: AccountRepository, private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository { ) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile

View File

@@ -1,16 +1,15 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile import su.reya.coop.Profile
@@ -19,29 +18,43 @@ import kotlin.time.Duration.Companion.milliseconds
class ProfileCache( class ProfileCache(
private val nostr: Nostr, private val nostr: Nostr,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
private val profilesMutex = Mutex() private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
scope.launch { runObserver() }
scope.launch { runMetadataBatching() }
scope.launch { scope.launch {
try {
runObserver()
} catch (e: Exception) {
showError("Metadata observer failed: ${e.message}")
}
}
scope.launch {
try {
runMetadataBatching()
} catch (e: Exception) {
showError("Metadata batching failed: ${e.message}")
}
}
scope.launch {
try {
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
loadCacheMetadata() getCachedMetadata()
} catch (e: Exception) {
showError("Failed to load initial cache: ${e.message}")
}
} }
} }
private suspend fun runObserver() = coroutineScope { private suspend fun runObserver() {
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata)) updateMetadata(pubkey, Profile(pubkey, metadata))
} }
} }
}
private suspend fun runMetadataBatching() { private suspend fun runMetadataBatching() {
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -57,37 +70,69 @@ class ProfileCache(
batch.add(nextKey) batch.add(nextKey)
} }
try {
nostr.profiles.fetchMetadataBatch(batch.toList()) nostr.profiles.fetchMetadataBatch(batch.toList())
} catch (e: Exception) {
// Allow these keys to be requested again since the fetch failed
seenPublicKeys.update { it - batch }
println("Failed to fetch metadata batch: ${e.message}")
}
} }
} }
private suspend fun loadCacheMetadata() { private suspend fun getCachedMetadata() {
val cache = nostr.profiles.getAllCacheMetadata() val cache = nostr.profiles.getAllCacheMetadata()
profilesMutex.withLock {
cache.forEach { (pubkey, metadata) -> cache.forEach { (pubkey, metadata) ->
val profile = Profile(pubkey, metadata) updateMetadata(pubkey, Profile(pubkey, metadata))
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
seenPublicKeys.add(pubkey)
}
} }
} }
private fun requestMetadata(pubkey: PublicKey) { private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) { var added = false
metadataRequestChannel.trySend(pubkey) seenPublicKeys.update { current ->
if (current.contains(pubkey)) {
added = false
current
} else {
added = true
current + pubkey
} }
} }
if (added) metadataRequestChannel.trySend(pubkey)
}
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
profilesMutex.withLock { profiles.update { current ->
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile val flow = current[pubkey] ?: MutableStateFlow<Profile?>(null)
flow.value = profile
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
} }
seenPublicKeys.update { it + pubkey }
} }
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> { fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } val currentMap = profiles.value
if (flow.value == null) requestMetadata(pubkey) val existingFlow = currentMap[pubkey]
return flow.asStateFlow()
if (existingFlow != null) {
if (existingFlow.value == null) requestMetadata(pubkey)
return existingFlow.asStateFlow()
}
val newFlow = MutableStateFlow<Profile?>(null)
var resultFlow = newFlow
profiles.update { prev ->
if (prev.containsKey(pubkey)) {
resultFlow = prev[pubkey]!!
prev
} else {
prev + (pubkey to newFlow)
}
}
if (resultFlow.value == null) requestMetadata(pubkey)
return resultFlow.asStateFlow()
} }
} }