diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1b639de..89f75cb 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -18,15 +18,29 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar"> + + + + + + + + + + + + { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun App() { +fun App(viewModel: NostrViewModel) { val context = LocalContext.current val navController = rememberNavController() val scope = rememberCoroutineScope() @@ -81,17 +80,15 @@ fun App() { // Snackbar val snackbarHostState = remember { SnackbarHostState() } - // Initialize Nostr View Model and Secret Store - val secretStore = remember { SecretStore(context) } - val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) } - // 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 -> { if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - + // When dark mode is enabled, use the dark color scheme darkMode -> darkColorScheme() + // Fallback to the light color scheme else -> expressiveLightColorScheme() } @@ -111,21 +108,107 @@ fun App() { LocalSnackbarHostState provides snackbarHostState, LocalNavController provides navController, ) { - val emptySecret by viewModel.emptySecret.collectAsState(initial = null) + val signerRequired by viewModel.signerRequired.collectAsState(initial = null) val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() val sheetState = rememberModalBottomSheetState() - LaunchedEffect(emptySecret) { + LaunchedEffect(signerRequired) { // Navigate to the home screen if the secret is already set - if (emptySecret == false) { + if (signerRequired == false) { navController.navigate(Screen.Home) { popUpTo(Screen.Onboarding) { inclusive = true } } } } - // Show loading screen while initializing - if (emptySecret == null) return@CompositionLocalProvider + // Keep the splash screen visible until the secret check is complete + if (signerRequired == null) { + return@CompositionLocalProvider + } + + NavHost( + navController = navController, + startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home + ) { + composable { backStackEntry -> + OnboardingScreen( + onOpenImport = { navController.navigate(Screen.Import) }, + onOpenNew = { navController.navigate(Screen.NewIdentity) } + ) + } + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() + + ImportScreen( + isLoading = isCreating, + onBack = { navController.popBackStack() }, + onSave = { secret -> + viewModel.importIdentity(secret) + } + ) + } + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() + + NewIdentityScreen( + isLoading = isCreating, + onBack = { navController.popBackStack() }, + onSave = { name, bio, uri -> + val contentType = uri?.let { context.contentResolver.getType(it) } + val picture = uri?.let { + context.contentResolver.openInputStream(it)?.use { input -> + input.readBytes() + } + } + viewModel.createIdentity(name, bio, picture, contentType) + } + ) + } + composable { backStackEntry -> + HomeScreen( + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }, + onNewChat = { navController.navigate(Screen.NewChat) } + ) + } + composable( + deepLinks = listOf( + navDeepLink(basePath = "coop://chat") + ) + ) { backStackEntry -> + val chat: Screen.Chat = backStackEntry.toRoute() + ChatScreen( + id = chat.id, + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + val profile: Screen.Profile = backStackEntry.toRoute() + ProfileScreen( + pubkey = profile.pubkey, + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + NewChatScreen( + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + ScanScreen( + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + MyQrScreen( + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + RelayScreen( + onBack = { navController.popBackStack() }, + ) + } + } // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { @@ -181,86 +264,6 @@ fun App() { } } } - - NavHost( - navController = navController, - startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding - ) { - composable { backStackEntry -> - OnboardingScreen( - onOpenImport = { navController.navigate(Screen.Import) }, - onOpenNew = { navController.navigate(Screen.NewIdentity) } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() - - ImportScreen( - isLoading = isCreating, - onBack = { navController.popBackStack() }, - onSave = { secret -> - viewModel.importIdentity(secret) - } - ) - } - composable { backStackEntry -> - val isCreating by viewModel.isCreating.collectAsState() - - NewIdentityScreen( - isLoading = isCreating, - onBack = { navController.popBackStack() }, - onSave = { name, bio, uri -> - val contentType = uri?.let { context.contentResolver.getType(it) } - val picture = uri?.let { - context.contentResolver.openInputStream(it)?.use { input -> - input.readBytes() - } - } - viewModel.createIdentity(name, bio, picture, contentType) - } - ) - } - composable { backStackEntry -> - HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }, - onNewChat = { navController.navigate(Screen.NewChat) } - ) - } - composable { backStackEntry -> - val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen( - id = chat.id, - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - val profile: Screen.Profile = backStackEntry.toRoute() - ProfileScreen( - pubkey = profile.pubkey, - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - NewChatScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - ScanScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - MyQrScreen( - onBack = { navController.popBackStack() }, - ) - } - composable { backStackEntry -> - RelayScreen( - onBack = { navController.popBackStack() }, - ) - } - } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 1bde4f8..0f7dffc 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -6,25 +6,48 @@ 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.ViewModel +import androidx.lifecycle.ViewModelProvider +import su.reya.coop.coop.storage.SecretStore class MainActivity : ComponentActivity() { + private val viewModel: NostrViewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val secretStore = SecretStore(this@MainActivity) + return NostrViewModel(NostrManager.instance, secretStore) as T + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + val splashScreen = installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) - val intent = Intent(this, NostrForegroundService::class.java) + val serviceIntent = Intent(this, NostrForegroundService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) + startForegroundService(serviceIntent) } else { - startService(intent) + startService(serviceIntent) + } + + // Keep the splash screen visible until the signer check is complete + splashScreen.setKeepOnScreenCondition { + viewModel.signerRequired.value == null } setContent { - App() + App(viewModel = viewModel) } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 4d53f16..48e9bc3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -3,12 +3,14 @@ package su.reya.coop import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import kotlinx.coroutines.CoroutineScope @@ -31,7 +33,8 @@ class NostrForegroundService : Service() { @RequiresApi(Build.VERSION_CODES.O) override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { createNotificationChannel() - val notification = createNotification("Connecting to Nostr...") + + val notification = createNotification() startForeground(1, notification) serviceScope.launch { @@ -43,11 +46,25 @@ class NostrForegroundService : Service() { // Connect to bootstrap relays nostr.connectBootstrapRelays() // Handle notifications - nostr.handleLiteNotifications { event -> - if (!isUserInApp()) { - showNewMessageNotification(event.content()) + nostr.handleNotifications( + onMetadataUpdate = { pubkey, metadata -> + serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) } + }, + onContactListUpdate = { contacts -> + serviceScope.launch { nostr.emitContactListUpdate(contacts) } + }, + onSubscriptionClose = { + serviceScope.launch { nostr.emitSubscriptionClosed() } + }, + onNewMessage = { event -> + serviceScope.launch { + if (!isUserInApp()) { + showNewMessageNotification(event.roomId(), event.content()) + } + nostr.emitNewEvent(event) + } } - } + ) } catch (e: Exception) { println("Failed to start Nostr in background: ${e.message}") } @@ -58,30 +75,68 @@ class NostrForegroundService : Service() { @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel() { - val channel = NotificationChannel( - "nostr_service", - "Nostr Background Service", + val manager = getSystemService(NotificationManager::class.java) + + val serviceChannel = NotificationChannel( + "nostr_service_silent", + "Nostr Background Status", + NotificationManager.IMPORTANCE_MIN + ).apply { + setShowBadge(false) + } + manager?.createNotificationChannel(serviceChannel) + + val messageChannel = NotificationChannel( + "nostr_messages", + "New Messages", NotificationManager.IMPORTANCE_HIGH ) - val manager = getSystemService(NotificationManager::class.java) - manager?.createNotificationChannel(channel) + manager?.createNotificationChannel(messageChannel) } - private fun createNotification(content: String): Notification { - return NotificationCompat.Builder(this, "nostr_service") - .setContentTitle("Coop") - .setContentText(content) - .setSmallIcon(android.R.drawable.ic_menu_send) + private fun createNotification(content: String? = null): Notification { + val builder = NotificationCompat.Builder(this, "nostr_service") + .setSmallIcon(R.drawable.ic_notification) .setOngoing(true) - .build() + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_SERVICE) + + if (content != null) { + builder.setContentTitle("Coop") + builder.setContentText(content) + } else { + builder.setContentTitle("Coop is active") + } + + return builder.build() } - private fun showNewMessageNotification(message: String) { - val notification = NotificationCompat.Builder(this, "nostr_service") - .setContentTitle("New Message") + private fun showNewMessageNotification(roomId: Long, message: String) { + val deepLinkUri = "coop://chat/$roomId".toUri() + + val intent = Intent( + Intent.ACTION_VIEW, + deepLinkUri, + this, + MainActivity::class.java + ) + + val pendingIntent = PendingIntent.getActivity( + this, + roomId.toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(this, "nostr_messages") + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("You received a new message") .setContentText(message) .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setCategory(Notification.CATEGORY_MESSAGE) .build() + val manager = getSystemService(NotificationManager::class.java) manager?.notify(System.currentTimeMillis().toInt(), notification) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index c7f8f87..a3ab8a8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -90,19 +93,16 @@ fun ChatScreen( var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } + var newOtherMessages by remember { mutableIntStateOf(0) } val messages = remember { mutableStateListOf() } val groupedMessages = remember(messages.toList()) { messages.groupBy { it.createdAt().formatAsGroupHeader() } } - fun setLoading(value: Boolean) { - loading = value - } - LaunchedEffect(id) { // Start loading spinner - setLoading(true) + loading = true // Get messages val initialMessages = viewModel.getChatRoomMessages(id) @@ -122,7 +122,7 @@ fun ChatScreen( } // Stop loading spinner - setLoading(false) + loading = false // Handle new messages viewModel.newEvents.collect { event -> @@ -130,6 +130,9 @@ fun ChatScreen( if (event.id() !in messages.map { it.id() }) { messages.add(0, event) } + } else { + // If the event is not in the current room, it's a new message from another user + newOtherMessages++ } } } @@ -173,11 +176,21 @@ fun ChatScreen( } }, navigationIcon = { - IconButton(onClick = onBack) { - Icon( - painter = painterResource(Res.drawable.ic_arrow_back), - contentDescription = "Back" - ) + BadgedBox( + badge = { + if (newOtherMessages > 0) { + Badge { + Text(newOtherMessages.toString()) + } + } + } + ) { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } } }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..c661ce6 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..3baa6d2 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..71caf07 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..e7523a5 Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png differ diff --git a/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000..afc41ad Binary files /dev/null and b/composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 54b5a70..c56555c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -6,13 +6,13 @@ import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.Alphabet import rust.nostr.sdk.AsyncNostrSigner @@ -62,9 +62,6 @@ object NostrManager { } class Nostr { - private val _isInitialized = MutableStateFlow(false) - val isInitialized: StateFlow = _isInitialized.asStateFlow() - var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) @@ -76,9 +73,35 @@ class Nostr { var rumorMap: MutableMap = mutableMapOf() private set + private val isInitialized = MutableStateFlow(false) + + // Add these to the Nostr class + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _metadataUpdates = + MutableSharedFlow>(extraBufferCapacity = 100) + val metadataUpdates = _metadataUpdates.asSharedFlow() + + private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + val contactListUpdates = _contactListUpdates.asSharedFlow() + + private val _subscriptionClosed = MutableSharedFlow(extraBufferCapacity = 10) + val subscriptionClosed = _subscriptionClosed.asSharedFlow() + + suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event) + + suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit) + + suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) = + _metadataUpdates.emit(pubkey to metadata) + + suspend fun emitContactListUpdate(contacts: List) = + _contactListUpdates.emit(contacts) + suspend fun init(dbPath: String) { try { - if (_isInitialized.value) return + if (isInitialized.value) return // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) @@ -108,14 +131,14 @@ class Nostr { .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() - _isInitialized.value = true + isInitialized.value = true } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } suspend fun waitUntilInitialized() { - _isInitialized.first { it } + isInitialized.first { it } } suspend fun connectBootstrapRelays() { @@ -147,8 +170,6 @@ class Nostr { suspend fun setSigner(new: AsyncNostrSigner) { try { signer.switch(new) - // Fetch metadata for current user - getUserMetadata() } catch (e: Exception) { throw IllegalStateException("Failed to set signer: ${e.message}", e) } @@ -216,70 +237,15 @@ class Nostr { } } - suspend fun handleLiteNotifications( - onNewMessage: (UnsignedEvent) -> Unit, - ) { - val now = Timestamp.now() - val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return - - while (true) { - val notification = notifications.next() ?: continue - - when (notification) { - is ClientNotification.Message -> { - val relayUrl = notification.relayUrl - - when (val message = notification.message.asEnum()) { - is RelayMessageEnum.EventMsg -> { - val event = message.event - val subscriptionId = message.subscriptionId - - // Ignore events not from the newest gift wraps subscription - if (subscriptionId != "newest-gift-wraps") continue - - // Prevent processing duplicate events - if (processedEvent.contains(event.id())) continue - processedEvent.add(event.id()) - - if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { - try { - val rumor = extractRumor(event) - - // Handle new message - rumor?.createdAt()?.asSecs()?.let { - if (it >= now.asSecs()) { - onNewMessage(rumor) - } - } - } catch (e: Exception) { - println("Failed to extract rumor: $e") - } - } - } - - else -> { - /* Ignore other event kinds */ - } - } - } - - else -> { - /* Ignore other message types */ - } - } - } - } - suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, onContactListUpdate: (List) -> Unit, onNewMessage: (UnsignedEvent) -> Unit, onSubscriptionClose: () -> Unit, - ) = coroutineScope { + ) = supervisorScope { val now = Timestamp.now() val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return@coroutineScope + val notifications = client?.notifications() ?: return@supervisorScope var eoseTrackerJob: Job? = null @@ -293,7 +259,6 @@ class Nostr { when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event - val id = message.subscriptionId // Prevent processing duplicate events if (processedEvent.contains(event.id())) continue diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 241090d..1141252 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -39,8 +39,8 @@ class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage ) : ViewModel() { - private val _emptySecret = MutableStateFlow(null) - val emptySecret = _emptySecret.asStateFlow() + private val _signerRequired = MutableStateFlow(null) + val signerRequired = _signerRequired.asStateFlow() private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() @@ -71,11 +71,20 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() init { - startNotificationHandler() - startMetadataBatchHandler() - getCacheMetadata() + // Check local stored secret (secret key or bunker) login() + + // Observe the signer state and verify the relay list observeSignerAndCheckRelays() + + // Get all local stored metadata + getCacheMetadata() + + // Observe new events from the Nostr client + runObserver() + + // Wait and merge metadata requests into a single batch + runMetadataBatching() } override fun onCleared() { @@ -95,35 +104,53 @@ class NostrViewModel( } } - private fun startNotificationHandler() { + private fun runObserver() { viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() + // Observe new messages + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - nostr.handleNotifications( - onMetadataUpdate = { pubkey, metadata -> + 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) + } + } + + // Observe metadata updates + launch { + nostr.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, metadata) - }, - onContactListUpdate = { contactList -> - _contactList.value = contactList.toSet() - }, - onSubscriptionClose = { - getChatRooms() + } + } - if (!_isPartialProcessedGiftWrap.value) { - _isPartialProcessedGiftWrap.value = true - } - }, - onNewMessage = { event -> - viewModelScope.launch { - _newEvents.emit(event) - } - }, - ) + // Observe contact list updates + launch { + nostr.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + + // Observes subscription close + launch { + nostr.subscriptionClosed.collect { + getChatRooms() + _isPartialProcessedGiftWrap.value = true + } + } } } - private fun startMetadataBatchHandler() { + private fun runMetadataBatching() { viewModelScope.launch { // Wait until the client is ready nostr.waitUntilInitialized() @@ -164,7 +191,9 @@ class NostrViewModel( val results = nostr.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> + // Update the metadata state updateMetadata(pubkey, metadata) + // Update seenPublicKeys to avoid duplicate requests seenPublicKeys.add(pubkey) } } @@ -172,22 +201,18 @@ class NostrViewModel( private fun login() { viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - // Get user's signer secret val secret = secretStore.get("user_signer") // If no secret is found, show onboarding screen - when (secret) { - null -> { - _emptySecret.value = true - return@launch - } - - else -> _emptySecret.value = false + if (secret == null) { + _signerRequired.value = true + return@launch } + // Update the empty secret state + _signerRequired.value = false + // Handle different signer types if (secret.startsWith("nsec1")) { val keys = Keys.parse(secret) @@ -197,8 +222,7 @@ class NostrViewModel( val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) val timeout = Duration.parse("50s") // 50 seconds timeout - val remote = - NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null) nostr.setSigner(remote) } catch (e: Exception) { showError("Error: ${e.message}") @@ -215,15 +239,29 @@ class NostrViewModel( val pubkey = nostr.signer.currentUser if (pubkey != null) { + // Get chat rooms + val rooms = nostr.getChatRooms() ?: emptySet() + if (rooms.isNotEmpty()) { + _chatRooms.value = rooms + _isPartialProcessedGiftWrap.value = true + } + + // Get all metadata for the current user + nostr.getUserMetadata() + + // Small delay to ensure all relays are connected delay(3000) + + // Check if the relay list is empty val relays = nostr.getMsgRelays(pubkey) if (relays.isEmpty()) { _isRelayListEmpty.value = true } + break } - delay(1000) + delay(500) } } } @@ -256,7 +294,7 @@ class NostrViewModel( viewModelScope.launch { secretStore.clear("user_signer") nostr.signer.switch(Keys.generate()) - _emptySecret.value = true + _signerRequired.value = true } } @@ -325,7 +363,7 @@ class NostrViewModel( secretStore.set("user_signer", secret) // Set an empty secret state - _emptySecret.value = false + _signerRequired.value = false } catch (e: Exception) { showError("Error: ${e.message}") } @@ -358,18 +396,16 @@ class NostrViewModel( nostr.setSigner(keys) secretStore.set("user_signer", secret) // Set an empty secret state - _emptySecret.value = false + _signerRequired.value = false } else if (secret.startsWith("bunker://")) { try { val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) val timeout = Duration.parse("50s") // 50 seconds timeout - val remote = - NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + val remote = NostrConnect(uri = bunker, appKeys, timeout, null) nostr.setSigner(remote) secretStore.set("user_signer", secret) - // Set an empty secret state - _emptySecret.value = false + _signerRequired.value = false } catch (e: Exception) { showError("Error: ${e.message}") } @@ -411,11 +447,13 @@ class NostrViewModel( if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + val currentUser = nostr.signer.currentUser!! + // Construct the rumor event val rumor = EventBuilder .privateMsgRumor(to.first(), "") .tags(to.map { Tag.publicKey(it) }) - .build(nostr.signer.currentUser!!) + .build(currentUser) // Check if the room already exists val id = rumor.roomId() @@ -427,7 +465,7 @@ class NostrViewModel( } // Create a room from the rumor event - val room = Room.new(rumor, nostr.signer.currentUser!!) + val room = Room.new(rumor, currentUser) // Update the chat rooms state _chatRooms.update { currentRooms -> @@ -522,13 +560,18 @@ class NostrViewModel( } private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.value = _chatRooms.value.map { room -> - if (room.id == roomId) { - room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) - } else { - room - } - }.toSet() + _chatRooms.update { currentRooms -> + currentRooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + } } suspend fun searchByAddress(query: String): PublicKey? { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 3e2ff19..373f787 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -40,10 +40,10 @@ data class Room( val subject = rumor.tags().find(TagKind.Subject)?.content() // Collect the author's public key and all public keys from tags - // Also remove the user's public key from the list, current user is always a member val pubkeys: MutableSet = mutableSetOf() pubkeys.add(rumor.author()) pubkeys.addAll(rumor.tags().publicKeys()) + // Also remove the user's public key from the list, current user is always a member pubkeys.remove(userPubkey) // Create a new Room instance