diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 4f16cc4..4044658 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -328,7 +327,7 @@ fun ContactListItem( ) { val nostrViewModel = LocalNostrViewModel.current val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val profile by profileFlow.collectAsState(initial = null) + val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null) SegmentedListItem( onClick = onClick, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index e4f845e..a3e1d38 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -164,7 +164,7 @@ class MessageManager(private val nostr: Nostr) { try { val userPubkey = signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 330fd31..0ef8a3e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -2,11 +2,13 @@ package su.reya.coop.nostr import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import rust.nostr.sdk.AsyncNostrSigner @@ -30,6 +32,7 @@ import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.initLogger import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds object NostrManager { val instance = Nostr() @@ -59,17 +62,18 @@ class Nostr { private val isInitialized = MutableStateFlow(false) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - suspend fun emitNewEvent(event: UnsignedEvent) { - _newEvents.emit(event) + fun emitNewEvent(event: UnsignedEvent) { + _newEvents.tryEmit(event) } - suspend fun init( - dbPath: String, - logLevel: LogLevel = LogLevel.WARN - ) { + suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) { try { if (isInitialized.value) return @@ -105,7 +109,8 @@ class Nostr { } suspend fun waitUntilInitialized() { - isInitialized.first { it } + withTimeoutOrNull(30.seconds) { isInitialized.first { it } } + ?: throw IllegalStateException("Nostr initialization timed out") } suspend fun connectBootstrapRelays() { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index c276df9..7579420 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -4,6 +4,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import rust.nostr.sdk.AckPolicy @@ -33,10 +34,19 @@ class ProfileManager(private val nostr: Nostr) { private val signer: UniversalSigner get() = nostr.signer private val _metadataUpdates = - MutableSharedFlow>(extraBufferCapacity = 100) + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val metadataUpdates = _metadataUpdates.asSharedFlow() - private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + private val _contactListUpdates = + MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val contactListUpdates = _contactListUpdates.asSharedFlow() suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { @@ -138,19 +148,24 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - try { - val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() - val newRecord = record.copy( - displayName = name ?: record.displayName, - about = bio ?: record.about, - picture = picture ?: record.picture - ) - val newMetadata = Metadata.fromRecord(newRecord) - val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + // Get the latest metadata event + val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() + + // Build a new metadata based on old records + val newMetadata = Metadata.fromRecord( + record.copy( + displayName = name ?: record.displayName, + about = bio ?: record.about, + picture = picture ?: record.picture + ) + ) + + // Send the new metadata event + val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) client?.sendEvent( event = event, target = SendEventTarget.broadcast(), diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index d763018..6c7d57f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -104,6 +104,32 @@ class RelayManager(private val nostr: Nostr) { return msgRelayList } + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + + suspend fun setRelaylist(relays: Map) { + try { + val event = EventBuilder.relayList(relays).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + suspend fun setMsgRelays(urls: List) { try { val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) @@ -154,30 +180,4 @@ class RelayManager(private val nostr: Nostr) { throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) } } - - suspend fun getRelayList(publicKey: PublicKey): Map { - try { - val kind = Kind.fromStd(KindStandard.RELAY_LIST) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get relay list: ${e.message}", e) - } - } - - suspend fun setRelaylist(relays: Map) { - try { - val event = EventBuilder.relayList(relays).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt index 5ce2118..e4d17d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,11 +1,16 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow abstract class BaseViewModel : ViewModel() { - private val _errorEvents = MutableSharedFlow(extraBufferCapacity = 10) + private val _errorEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.SUSPEND + ) val errorEvents = _errorEvents.asSharedFlow() protected fun showError(message: String) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index b780371..aaf9dbd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -2,11 +2,11 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -16,7 +16,6 @@ import rust.nostr.sdk.EventId import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.Room @@ -26,7 +25,6 @@ import su.reya.coop.roomId data class ChatState( val rooms: Map = emptyMap(), - val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, ) @@ -35,25 +33,23 @@ class ChatViewModel( private val mediaRepository: MediaRepository, ) : BaseViewModel() { private val _state = MutableStateFlow(ChatState()) - val state = combine( - _state, - nostr.messages.messageSyncState - ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + val state = _state.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), ChatState() ) - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + private val _newEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 100, + onBufferOverflow = BufferOverflow.SUSPEND + ) val newEvents = _newEvents.asSharedFlow() - private val _sentReports = MutableSharedFlow>>() - val sentReport = _sentReports.asSharedFlow() - val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val isSyncing = state.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } @@ -92,7 +88,7 @@ class ChatViewModel( updateRoomList(roomId, event) } - _newEvents.emit(event) + _newEvents.tryEmit(event) } } } @@ -156,6 +152,8 @@ class ChatViewModel( suspend fun getChatRoomMessages(roomId: Long): List { try { return nostr.messages.getChatRoomMessages(roomId) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { showError("Error: ${e.message}") } @@ -191,7 +189,7 @@ class ChatViewModel( replies = replies, onRumorCreated = { event -> updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } + viewModelScope.launch { _newEvents.tryEmit(event) } }, ) } catch (e: Exception) {