4 Commits

Author SHA1 Message Date
2d830b778f clean up 2026-07-15 17:57:55 +07:00
fcc30157bb Revert "clean up chat screen"
This reverts commit 9c9d9ae882.
2026-07-15 17:02:55 +07:00
43618533a5 improve the profile cache 2026-07-15 08:18:05 +07:00
9c9d9ae882 clean up chat screen 2026-07-15 07:32:39 +07:00
9 changed files with 128 additions and 60 deletions

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

@@ -73,6 +73,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 +227,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 +252,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)
} }
@@ -340,6 +346,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()

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

@@ -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 {

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()
} }
} }