From e9eb0712081143414d13a35f5257ebac7f40ab58 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 29 May 2026 06:56:47 +0000 Subject: [PATCH] feat: implement basic notification (#6) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/6 --- .../src/androidMain/AndroidManifest.xml | 14 ++ .../androidMain/kotlin/su/reya/coop/App.kt | 189 +++++++++--------- .../kotlin/su/reya/coop/MainActivity.kt | 33 ++- .../su/reya/coop/NostrForegroundService.kt | 93 +++++++-- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 35 +++- .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 551 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 391 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 727 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 1124 bytes .../res/drawable-xxxhdpi/ic_notification.png | Bin 0 -> 1490 bytes .../commonMain/kotlin/su/reya/coop/Nostr.kt | 103 ++++------ .../kotlin/su/reya/coop/NostrViewModel.kt | 155 ++++++++------ .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- 13 files changed, 370 insertions(+), 254 deletions(-) create mode 100644 composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xhdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xxhdpi/ic_notification.png create mode 100644 composeApp/src/androidMain/res/drawable-xxxhdpi/ic_notification.png 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 0000000000000000000000000000000000000000..c661ce60fd9bf798af1084c0de581b82355cb52c GIT binary patch literal 551 zcmV+?0@(eDP)nS?G-dZBXQ{uD}$B zt~eg1k<9_ewlr-B`xUQkq~y{uh+0@S#(BcfkjR0>|!0Y4#nUyf3(84RX9LsTB1 zeQBSy%H2jVm}jRW&biwN221R8M1#AHV6ehYN3^)x2nNgSbVQT8eTe?N+JJUMpB+sH zQX736T7xEe4L&?WKPYpz5z!}TKd5lG5ezoi>4*|{8^PciI~|edZX+06VW$Jssh#F- zBN)th2A5oLZ%1ILae`%LxU(ZL>0K?;H(jxM4@QG`xT;^e>3$wgNuLC`@QMco9Qq^Je) z>-gLTF3FpwO>aPa;2F~Mo;M|FZ>jWOFtbs-$8)^K1J3S2ht^EtlVgonjvu*A5Fsw*8gfs2+FkI1e01fT+FkIDi z0Pot{g24_RS@g?j2I%&-a9X=tF!-|KtZA=@;e)0FsA{jrqQ_H|aqaamT-0=rbUs}P z3|hX!EsJ^WOhITCKXfw(2ild1MQ4jU>+^DoW$oz*3v+mv>`CFi#2yamelFZ1F5@vi l;kQM9s4LvX3ElZ~egRmt${xy6e18A{002ovPDHLkV1lAYwlDwy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..71caf070ff8661c06d1cf8645ed6f61c7586b0f9 GIT binary patch literal 727 zcmV;|0x127P)Nkl?gF6oOoM)Q3N!SF&0cd}Bx;X2%8jdx_9g&ytZ1%ul)EfIa%%?k!kXi_3BYd0?# zysb%zc%|LEVDP0TC1OImdBI>!lM*qh-MnCMRFe|%NxRj>+!g0m+=lz`J$|X!8eTMa z*EWc&wP#(-U6Z#07qpuf=1v@~z_@nvg25k}l!!6y<^_XqH7OAz+RY0F?`TpY&S^I< z7(Aj$iP)pvys&uJ|3%0SB+Ip%7YugbM+vN&$J(n91RU1HirKB53PHdUb2l6*7`G6Q zR0uhs$rZ6vyEFwsbMdLBN9HEBYnP@m_w@OzEQ!TCT&f*PWA3n;ed`#L&$v=|2ZQhp z=Dw7O!3XTsoxx$r3Vb2U%QAx9x+@wi>oRwaD0{`4{WtrtLw8gHi@Wipxx1$#WM}w8 z7UTJUGnjiYo85k%!Ut0Yr=MbHPupsE*WA}f+2<(DUwoEG`~@PvU14HRbld;{002ov JPDHLkV1jT=TKoV2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e7523a5665633abe6485329262e9e259f250f407 GIT binary patch literal 1124 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 literal 0 HcmV?d00001 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