diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index d98c73c..6eefaf2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -2,6 +2,7 @@ package su.reya.coop import android.app.Activity import android.content.Intent +import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme @@ -31,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -46,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.util.Consumer +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -93,8 +94,8 @@ fun App(viewModel: NostrViewModel) { val navigator = remember(backStack) { Navigator(backStack) } val qrScanResult = remember { QrScanResult() } - val signerRequired by viewModel.signerRequired.collectAsState(initial = null) - val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() + val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle() + val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle() // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -105,7 +106,7 @@ fun App(viewModel: NostrViewModel) { // Enabled the dynamic color scheme val colorScheme = when { // Enable the dynamic color scheme for Android 12+ - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( context ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 58a9af1..0e51ee7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -1,13 +1,13 @@ package su.reya.coop import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore @@ -50,18 +50,16 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) val serviceIntent = Intent(this, NostrForegroundService::class.java) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } + startForegroundService(serviceIntent) // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { viewModel.signerRequired.value == null } + // Bind the lifecycle of the ViewModel to the Activity's lifecycle' + viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) + setContent { App(viewModel = viewModel) } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 61f2391..9e2d8c8 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("su.reya:nostr-sdk-kmp:0.2.3") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 3536641..33ecfe5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -63,7 +63,6 @@ object NostrManager { val BOOTSTRAP_RELAYS = listOf( "wss://relay.primal.net", - "wss://user.kindpag.es", "wss://purplepag.es" ) @@ -612,7 +611,6 @@ class Nostr { ReqTarget.manual( mapOf( RelayUrl.parse("wss://purplepag.es") to listOf(filter), - RelayUrl.parse("wss://user.kindpag.es") to listOf(filter), RelayUrl.parse("wss://relay.primal.net") to listOf(filter), ) ) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 735e031..3532afd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -1,19 +1,26 @@ package su.reya.coop +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewModelScope import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -50,12 +57,6 @@ class NostrViewModel( private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn = _isLoggedIn.asStateFlow() - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() - - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - private val _isPartialProcessedGiftWrap = MutableStateFlow(false) val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() @@ -74,6 +75,28 @@ class NostrViewModel( private val _metadataStore = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() + private val manualRoomUpdates = MutableSharedFlow>() + private val _chatRooms = MutableStateFlow>(emptySet()) + + val chatRooms: StateFlow> = merge( + nostr.newEvents.map { event -> + processIncomingEvent(event) + _chatRooms.value + }, + manualRoomUpdates + ).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptySet() + ) + + val contactList: StateFlow> = nostr.contactListUpdates + .map { it.toSet() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptySet() + ) init { // Skip the splash screen if a user is already logged in @@ -95,12 +118,18 @@ class NostrViewModel( // Get all local stored metadata getCacheMetadata() + } - // Observe new events from the Nostr client - runObserver() - - // Wait and merge metadata requests into a single batch - runMetadataBatching() + fun bindLifecycle(lifecycle: Lifecycle) { + viewModelScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + coroutineScope { + launch { refreshChatRooms() } + launch { runObserver() } + launch { runMetadataBatching() } + } + } + } } override fun onCleared() { @@ -134,81 +163,66 @@ class NostrViewModel( } } - private fun runObserver() { - viewModelScope.launch { - // Observe new messages - launch { - nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } + private fun processIncomingEvent(event: UnsignedEvent) { + val roomId = event.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } - } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } + if (existingRoom == null) { + nostr.signer.currentUser?.let { user -> + val newRoom = Room.new(event, user) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } } + } else { + updateRoomList(roomId, event) + } + } - // Observe metadata updates - launch { - nostr.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - } + private suspend fun runObserver() = coroutineScope { + // Observe metadata updates + launch { + nostr.metadataUpdates.collect { (pubkey, metadata) -> + updateMetadata(pubkey, metadata) } + } - // Observe contact list updates - launch { - nostr.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - - // Observes subscription close - launch { - nostr.subscriptionClosed.collect { - getChatRooms() - _isPartialProcessedGiftWrap.value = true - } + // Observes subscription close + launch { + nostr.subscriptionClosed.collect { + getChatRooms() + _isPartialProcessedGiftWrap.value = true } } } - private fun runMetadataBatching() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() + private suspend fun runMetadataBatching() = coroutineScope { + // Wait until the client is ready + nostr.waitUntilInitialized() - val batch = mutableSetOf() - val timeout = 500L // 500ms timeout for batching + val batch = mutableSetOf() + val timeout = 500L // 500ms timeout for batching - while (true) { - val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) - val lastFlushTime = Clock.System.now().toEpochMilliseconds() + while (true) { + val firstKey = metadataRequestChannel.receive() + batch.add(firstKey) + val lastFlushTime = Clock.System.now().toEpochMilliseconds() - while (batch.isNotEmpty()) { - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - metadataRequestChannel.receive() - } + while (batch.isNotEmpty()) { + val nextKey = withTimeoutOrNull(timeout.milliseconds) { + metadataRequestChannel.receive() + } - if (nextKey != null) { - batch.add(nextKey) - } + // Only add the key if it's not null + if (nextKey != null) batch.add(nextKey) - val now = Clock.System.now().toEpochMilliseconds() - if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { - val keysToRequest = batch.toList() - batch.clear() + // Get current time + val now = Clock.System.now().toEpochMilliseconds() - nostr.fetchMetadataBatch(keysToRequest) - } + // Check if the batch is full or timeout has passed + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { + val keysToRequest = batch.toList() + batch.clear() + + nostr.fetchMetadataBatch(keysToRequest) } } }