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