feat: implement basic notification (#6)
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 551 B |
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
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 |
@@ -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
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user