feat: implement basic notification (#6)

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-05-29 06:56:47 +00:00
parent a2a4433a9d
commit e9eb071208
13 changed files with 370 additions and 254 deletions

View File

@@ -18,15 +18,29 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.App.Starting"> android:theme="@style/Theme.App.Starting">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="chat"
android:scheme="coop" />
</intent-filter>
</activity> </activity>
<service <service
android:name=".NostrForegroundService" android:name=".NostrForegroundService"
android:enabled="true" android:enabled="true"

View File

@@ -39,14 +39,13 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute import androidx.navigation.toRoute
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -72,7 +71,7 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun App() { fun App(viewModel: NostrViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -81,17 +80,15 @@ fun App() {
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } 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 // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
// Fallback to the light color scheme
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
@@ -111,21 +108,107 @@ fun App() {
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController, 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 isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
LaunchedEffect(emptySecret) { LaunchedEffect(signerRequired) {
// Navigate to the home screen if the secret is already set // Navigate to the home screen if the secret is already set
if (emptySecret == false) { if (signerRequired == false) {
navController.navigate(Screen.Home) { navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true } popUpTo(Screen.Onboarding) { inclusive = true }
} }
} }
} }
// Show loading screen while initializing // Keep the splash screen visible until the secret check is complete
if (emptySecret == null) return@CompositionLocalProvider if (signerRequired == null) {
return@CompositionLocalProvider
}
NavHost(
navController = navController,
startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) },
onOpenNew = { navController.navigate(Screen.NewIdentity) }
)
}
composable<Screen.Import> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
ImportScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
composable<Screen.NewIdentity> { 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<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
)
}
composable<Screen.Chat>(
deepLinks = listOf(
navDeepLink<Screen.Chat>(basePath = "coop://chat")
)
) { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
pubkey = profile.pubkey,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.MyQr> { backStackEntry ->
MyQrScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Relay> { backStackEntry ->
RelayScreen(
onBack = { navController.popBackStack() },
)
}
}
// Show the relay setup dialog if the msg relay list is empty // Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) { if (isRelayListEmpty) {
@@ -181,86 +264,6 @@ fun App() {
} }
} }
} }
NavHost(
navController = navController,
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) },
onOpenNew = { navController.navigate(Screen.NewIdentity) }
)
}
composable<Screen.Import> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
ImportScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
composable<Screen.NewIdentity> { 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<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
)
}
composable<Screen.Chat> { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
pubkey = profile.pubkey,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.MyQr> { backStackEntry ->
MyQrScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Relay> { backStackEntry ->
RelayScreen(
onBack = { navController.popBackStack() },
)
}
}
} }
} }
} }

View File

@@ -6,25 +6,48 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 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() { class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
return NostrViewModel(NostrManager.instance, secretStore) as T
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() val splashScreen = installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent) startForegroundService(serviceIntent)
} else { } else {
startService(intent) startService(serviceIntent)
}
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null
} }
setContent { setContent {
App() App(viewModel = viewModel)
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
} }

View File

@@ -3,12 +3,14 @@ package su.reya.coop
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -31,7 +33,8 @@ class NostrForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel() createNotificationChannel()
val notification = createNotification("Connecting to Nostr...")
val notification = createNotification()
startForeground(1, notification) startForeground(1, notification)
serviceScope.launch { serviceScope.launch {
@@ -43,11 +46,25 @@ class NostrForegroundService : Service() {
// Connect to bootstrap relays // Connect to bootstrap relays
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications
nostr.handleLiteNotifications { event -> 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()) { if (!isUserInApp()) {
showNewMessageNotification(event.content()) showNewMessageNotification(event.roomId(), event.content())
}
nostr.emitNewEvent(event)
} }
} }
)
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}") println("Failed to start Nostr in background: ${e.message}")
} }
@@ -58,30 +75,68 @@ class NostrForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() { private fun createNotificationChannel() {
val channel = NotificationChannel( val manager = getSystemService(NotificationManager::class.java)
"nostr_service",
"Nostr Background Service", 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 NotificationManager.IMPORTANCE_HIGH
) )
val manager = getSystemService(NotificationManager::class.java) manager?.createNotificationChannel(messageChannel)
manager?.createNotificationChannel(channel)
} }
private fun createNotification(content: String): Notification { private fun createNotification(content: String? = null): Notification {
return NotificationCompat.Builder(this, "nostr_service") val builder = NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("Coop") .setSmallIcon(R.drawable.ic_notification)
.setContentText(content)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setOngoing(true) .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")
} }
private fun showNewMessageNotification(message: String) { return builder.build()
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) .setContentText(message)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_MESSAGE)
.build() .build()
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager?.notify(System.currentTimeMillis().toInt(), notification) manager?.notify(System.currentTimeMillis().toInt(), notification)
} }

View File

@@ -19,6 +19,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -90,19 +93,16 @@ fun ChatScreen(
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) }
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) { val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() } messages.groupBy { it.createdAt().formatAsGroupHeader() }
} }
fun setLoading(value: Boolean) {
loading = value
}
LaunchedEffect(id) { LaunchedEffect(id) {
// Start loading spinner // Start loading spinner
setLoading(true) loading = true
// Get messages // Get messages
val initialMessages = viewModel.getChatRoomMessages(id) val initialMessages = viewModel.getChatRoomMessages(id)
@@ -122,7 +122,7 @@ fun ChatScreen(
} }
// Stop loading spinner // Stop loading spinner
setLoading(false) loading = false
// Handle new messages // Handle new messages
viewModel.newEvents.collect { event -> viewModel.newEvents.collect { event ->
@@ -130,6 +130,9 @@ fun ChatScreen(
if (event.id() !in messages.map { it.id() }) { if (event.id() !in messages.map { it.id() }) {
messages.add(0, event) messages.add(0, event)
} }
} else {
// If the event is not in the current room, it's a new message from another user
newOtherMessages++
} }
} }
} }
@@ -173,12 +176,22 @@ fun ChatScreen(
} }
}, },
navigationIcon = { navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
) )
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -6,13 +6,13 @@ import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet import rust.nostr.sdk.Alphabet
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
@@ -62,9 +62,6 @@ object NostrManager {
} }
class Nostr { class Nostr {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -76,9 +73,35 @@ class Nostr {
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf() var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private set private set
private val isInitialized = MutableStateFlow(false)
// Add these to the Nostr class
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(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<PublicKey>) =
_contactListUpdates.emit(contacts)
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try { try {
if (_isInitialized.value) return if (isInitialized.value) return
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(LogLevel.DEBUG)
@@ -108,14 +131,14 @@ class Nostr {
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build() .build()
_isInitialized.value = true isInitialized.value = true
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
} }
} }
suspend fun waitUntilInitialized() { suspend fun waitUntilInitialized() {
_isInitialized.first { it } isInitialized.first { it }
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
@@ -147,8 +170,6 @@ class Nostr {
suspend fun setSigner(new: AsyncNostrSigner) { suspend fun setSigner(new: AsyncNostrSigner) {
try { try {
signer.switch(new) signer.switch(new)
// Fetch metadata for current user
getUserMetadata()
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to set signer: ${e.message}", e) 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<EventId>()
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( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit, onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit, onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit, onSubscriptionClose: () -> Unit,
) = coroutineScope { ) = supervisorScope {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@coroutineScope val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null var eoseTrackerJob: Job? = null
@@ -293,7 +259,6 @@ class Nostr {
when (val message = notification.message.asEnum()) { when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> { is RelayMessageEnum.EventMsg -> {
val event = message.event val event = message.event
val id = message.subscriptionId
// Prevent processing duplicate events // Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue if (processedEvent.contains(event.id())) continue

View File

@@ -39,8 +39,8 @@ class NostrViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage private val secretStore: SecretStorage
) : ViewModel() { ) : ViewModel() {
private val _emptySecret = MutableStateFlow<Boolean?>(null) private val _signerRequired = MutableStateFlow<Boolean?>(null)
val emptySecret = _emptySecret.asStateFlow() val signerRequired = _signerRequired.asStateFlow()
private val _isCreating = MutableStateFlow(false) private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow() val isCreating = _isCreating.asStateFlow()
@@ -71,11 +71,20 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
startNotificationHandler() // Check local stored secret (secret key or bunker)
startMetadataBatchHandler()
getCacheMetadata()
login() login()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays() 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() { override fun onCleared() {
@@ -95,35 +104,53 @@ class NostrViewModel(
} }
} }
private fun startNotificationHandler() { private fun runObserver() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Observe new messages
nostr.waitUntilInitialized() launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
nostr.handleNotifications( if (existingRoom == null) {
onMetadataUpdate = { pubkey, metadata -> val currentUser = nostr.signer.currentUser
updateMetadata(pubkey, metadata) if (currentUser != null) {
}, val newRoom = Room.new(event, currentUser)
onContactListUpdate = { contactList -> _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
if (!_isPartialProcessedGiftWrap.value) {
_isPartialProcessedGiftWrap.value = true
} }
}, } else {
onNewMessage = { event -> updateRoomList(roomId, event)
viewModelScope.launch { }
_newEvents.emit(event) _newEvents.emit(event)
} }
}, }
)
// Observe metadata updates
launch {
nostr.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
} }
} }
private fun startMetadataBatchHandler() { // 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 runMetadataBatching() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -164,7 +191,9 @@ class NostrViewModel(
val results = nostr.getAllCacheMetadata() val results = nostr.getAllCacheMetadata()
results.forEach { (pubkey, metadata) -> results.forEach { (pubkey, metadata) ->
// Update the metadata state
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
// Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey) seenPublicKeys.add(pubkey)
} }
} }
@@ -172,21 +201,17 @@ class NostrViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Get user's signer secret // Get user's signer secret
val secret = secretStore.get("user_signer") val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen // If no secret is found, show onboarding screen
when (secret) { if (secret == null) {
null -> { _signerRequired.value = true
_emptySecret.value = true
return@launch return@launch
} }
else -> _emptySecret.value = false // Update the empty secret state
} _signerRequired.value = false
// Handle different signer types // Handle different signer types
if (secret.startsWith("nsec1")) { if (secret.startsWith("nsec1")) {
@@ -197,8 +222,7 @@ class NostrViewModel(
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null)
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote) nostr.setSigner(remote)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
@@ -215,15 +239,29 @@ class NostrViewModel(
val pubkey = nostr.signer.currentUser val pubkey = nostr.signer.currentUser
if (pubkey != null) { 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) delay(3000)
// Check if the relay list is empty
val relays = nostr.getMsgRelays(pubkey) val relays = nostr.getMsgRelays(pubkey)
if (relays.isEmpty()) { if (relays.isEmpty()) {
_isRelayListEmpty.value = true _isRelayListEmpty.value = true
} }
break break
} }
delay(1000) delay(500)
} }
} }
} }
@@ -256,7 +294,7 @@ class NostrViewModel(
viewModelScope.launch { viewModelScope.launch {
secretStore.clear("user_signer") secretStore.clear("user_signer")
nostr.signer.switch(Keys.generate()) nostr.signer.switch(Keys.generate())
_emptySecret.value = true _signerRequired.value = true
} }
} }
@@ -325,7 +363,7 @@ class NostrViewModel(
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state // Set an empty secret state
_emptySecret.value = false _signerRequired.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -358,18 +396,16 @@ class NostrViewModel(
nostr.setSigner(keys) nostr.setSigner(keys)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state // Set an empty secret state
_emptySecret.value = false _signerRequired.value = false
} else if (secret.startsWith("bunker://")) { } else if (secret.startsWith("bunker://")) {
try { try {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote) nostr.setSigner(remote)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state _signerRequired.value = false
_emptySecret.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -411,11 +447,13 @@ class NostrViewModel(
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
val currentUser = nostr.signer.currentUser!!
// Construct the rumor event // Construct the rumor event
val rumor = EventBuilder val rumor = EventBuilder
.privateMsgRumor(to.first(), "") .privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) }) .tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!) .build(currentUser)
// Check if the room already exists // Check if the room already exists
val id = rumor.roomId() val id = rumor.roomId()
@@ -427,7 +465,7 @@ class NostrViewModel(
} }
// Create a room from the rumor event // 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 // Update the chat rooms state
_chatRooms.update { currentRooms -> _chatRooms.update { currentRooms ->
@@ -522,13 +560,18 @@ class NostrViewModel(
} }
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.value = _chatRooms.value.map { room -> _chatRooms.update { currentRooms ->
currentRooms.map { room ->
if (room.id == roomId) { if (room.id == roomId) {
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else { } else {
room room
} }
}.toSet() }.sortedDescending().toSet()
}
} }
suspend fun searchByAddress(query: String): PublicKey? { suspend fun searchByAddress(query: String): PublicKey? {

View File

@@ -40,10 +40,10 @@ data class Room(
val subject = rumor.tags().find(TagKind.Subject)?.content() val subject = rumor.tags().find(TagKind.Subject)?.content()
// Collect the author's public key and all public keys from tags // 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<PublicKey> = mutableSetOf() val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author()) pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys()) pubkeys.addAll(rumor.tags().publicKeys())
// Also remove the user's public key from the list, current user is always a member
pubkeys.remove(userPubkey) pubkeys.remove(userPubkey)
// Create a new Room instance // Create a new Room instance