14 Commits

Author SHA1 Message Date
50b7f7a3f3 chore: bump version 2026-06-07 07:37:30 +07:00
a65aa70a55 chore: optimize the battery usage (#16)
Reviewed-on: #16
2026-06-07 00:35:46 +00:00
74a37320fe fix: occasional splash screen hang (#15)
Reviewed-on: #15
2026-06-06 07:30:47 +00:00
b8b3b83952 feat: add update profile screen (#14)
Reviewed-on: #14
2026-06-06 05:50:32 +00:00
5c2115e8b7 chore: bump version 2026-06-04 09:06:00 +07:00
ec337b8756 feat: add self-chat (#13)
Reviewed-on: #13
2026-06-04 01:59:38 +00:00
fcae7d5825 fix: crash when getting all cached metadata (#12)
Reviewed-on: #12
2026-06-03 07:50:34 +00:00
1e90b8d4b1 chore: bump version 2026-06-03 08:35:28 +07:00
71a8240b1d feat: add notification permission banner (#11)
Reviewed-on: #11
2026-06-02 08:54:47 +00:00
ff383a7c6a feat: add crash screen (#10)
Reviewed-on: #10
2026-06-02 02:19:13 +00:00
15e8c984e2 fix: app doesn't navigate to home screen after create or import identity (#9)
Reviewed-on: #9
2026-06-01 13:47:15 +00:00
0da1371345 chore: bump version 2026-06-01 09:55:03 +07:00
b7c5b64022 fix: no member in room (#8)
Reviewed-on: #8
2026-06-01 02:53:56 +00:00
a3ab489d44 feat: migrate to navigation3 (#7)
Reviewed-on: #7
2026-05-31 01:28:09 +00:00
28 changed files with 1517 additions and 1009 deletions

View File

@@ -19,12 +19,14 @@ kotlin {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2") implementation("io.github.alexzhirkevich:qrose:1.1.2")
} }
@@ -67,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.1.3" versionName = "0.1.7"
} }
packaging { packaging {
resources { resources {

View File

@@ -19,6 +19,12 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".CrashActivity"
android:exported="false"
android:process=":crash_handler"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
</vector>

View File

@@ -1,5 +1,10 @@
package su.reya.coop package su.reya.coop
import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -27,10 +32,11 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -39,12 +45,15 @@ 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.navigation.NavController import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.composable import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation.compose.rememberNavController import androidx.navigation3.runtime.NavBackStack
import androidx.navigation.navDeepLink import androidx.navigation3.runtime.NavKey
import androidx.navigation.toRoute import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
@@ -56,6 +65,7 @@ import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -65,26 +75,41 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
val LocalNavController = staticCompositionLocalOf<NavController> { val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("No NavController provided") error("No Navigator provided")
}
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
error("No QrScanResult provided")
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun App(viewModel: NostrViewModel) { fun App(viewModel: NostrViewModel) {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController() val activity = context as? ComponentActivity
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val darkMode = isSystemInDarkTheme() val sheetState = rememberModalBottomSheetState()
val backStack = rememberNavBackStack(Screen.Home)
val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
} }
// When dark mode is enabled, use the dark color scheme // When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
@@ -92,12 +117,48 @@ fun App(viewModel: NostrViewModel) {
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message -> viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
LaunchedEffect(activity) {
activity?.let {
fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) {
navigator.navigate(screen)
}
}
// Handle the intent that started the Activity
handleIntent(it.intent)
// Handle new intents while the Activity is running
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
it.addOnNewIntentListener(listener)
}
}
LaunchedEffect(backStack.size) {
if (backStack.isEmpty()) {
(context as? Activity)?.finish()
}
}
LaunchedEffect(signerRequired) {
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
backStack.clear()
backStack.add(Screen.Onboarding)
}
}
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography(), typography = Typography(),
@@ -106,109 +167,58 @@ fun App(viewModel: NostrViewModel) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides viewModel, LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) { ) {
val signerRequired by viewModel.signerRequired.collectAsState(initial = null) NavDisplay(
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() backStack = backStack,
val sheetState = rememberModalBottomSheetState() onBack = {
if (backStack.size > 1) {
LaunchedEffect(signerRequired) { backStack.removeLastOrNull()
// Navigate to the home screen if the secret is already set } else {
if (signerRequired == false) { (context as? Activity)?.finish()
navController.navigate(Screen.Home) { }
popUpTo(Screen.Onboarding) { inclusive = true } },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen()
}
entry<Screen.Onboarding> {
OnboardingScreen()
}
entry<Screen.Import> {
ImportScreen()
}
entry<Screen.NewIdentity> {
NewIdentityScreen()
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
}
entry<Screen.NewChat> {
NewChatScreen()
}
entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey)
}
entry<Screen.UpdateProfile> {
UpdateProfileScreen()
}
entry<Screen.Scan> {
ScanScreen()
}
entry<Screen.MyQr> {
MyQrScreen()
}
entry<Screen.Relay> {
RelayScreen()
} }
} }
} )
// 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<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) {
@@ -267,3 +277,23 @@ fun App(viewModel: NostrViewModel) {
} }
} }
} }
class Navigator(private val backStack: NavBackStack<NavKey>) {
fun navigate(route: NavKey) {
backStack.add(route)
}
fun goBack() {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
}
}
class QrScanResult {
var content by mutableStateOf<String?>(null)
fun clear() {
content = null
}
}

View File

@@ -0,0 +1,108 @@
package su.reya.coop
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.system.exitProcess
class CrashActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val errorText = intent.getStringExtra("error") ?: "Unknown error"
setContent {
MaterialExpressiveTheme {
Scaffold(
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column {
Text(
"App Crashed",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.error
)
Text(
"Please copy the log below and send it to the developer.",
style = MaterialTheme.typography.bodySmall
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = errorText,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledTonalButton(
onClick = {
finish();
exitProcess(0)
},
modifier = Modifier.weight(1f)
) {
Text("Exit")
}
Button(
onClick = {
val clipboard =
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val data = ClipData.newPlainText("Crash Log", errorText)
clipboard.setPrimaryClip(data)
},
modifier = Modifier.weight(1f)
) {
Text("Copy")
}
}
}
}
}
)
}
}
}
}

View File

@@ -1,16 +1,17 @@
package su.reya.coop package su.reya.coop
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle 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.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels { private val viewModel: NostrViewModel by viewModels {
@@ -23,24 +24,42 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
android.util.Log.e(
"CoopCrash",
"Uncaught exception in thread ${thread.name}",
throwable
)
// Start the Crash Activity
val intent = Intent(this, CrashActivity::class.java).apply {
putExtra("error", throwable.stackTraceToString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
startActivity(intent)
// Exit
android.os.Process.killProcess(android.os.Process.myPid())
exitProcess(1)
}
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java) val serviceIntent = Intent(this, NostrForegroundService::class.java)
startForegroundService(serviceIntent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null viewModel.signerRequired.value == null
} }
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent { setContent {
App(viewModel = viewModel) App(viewModel = viewModel)
} }

View File

@@ -1,8 +1,25 @@
package su.reya.coop package su.reya.coop
import android.content.Intent
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
sealed interface Screen { sealed interface Screen : NavKey {
companion object {
fun fromIntent(intent: Intent): Screen? {
val data = intent.data ?: return null
if (data.scheme != "coop") return null
return when (data.host) {
// Matches coop://chat/{id}
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
// Matches coop://profile/{pubkey}
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
else -> null
}
}
}
@Serializable @Serializable
data object Home : Screen data object Home : Screen
@@ -12,6 +29,9 @@ sealed interface Screen {
@Serializable @Serializable
data class Profile(val pubkey: String) : Screen data class Profile(val pubkey: String) : Screen
@Serializable
data object UpdateProfile : Screen
@Serializable @Serializable
data object NewChat : Screen data object NewChat : Screen

View File

@@ -6,15 +6,17 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.annotation.RequiresApi import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri 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
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,7 +24,8 @@ import java.io.File
class NostrForegroundService : Service() { class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val nostr = NostrManager.instance private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -30,17 +33,30 @@ class NostrForegroundService : Service() {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
} }
@RequiresApi(Build.VERSION_CODES.O) override fun onCreate() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { super.onCreate()
createNotificationChannel() createNotificationChannel()
val notification = createNotification() val notification = createNotification()
startForeground(1, notification)
serviceScope.launch { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(1, notification)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (notificationJob?.isActive == true) return START_STICKY
notificationJob = serviceScope.launch {
try { try {
Log.d("Coop", "Starting Nostr in background")
// Create a database directory
val dbDir = File(filesDir, "nostr") val dbDir = File(filesDir, "nostr")
dbDir.mkdirs() dbDir.mkdirs()
// Initialize Nostr client // Initialize Nostr client
nostr.init(dbDir.absolutePath) nostr.init(dbDir.absolutePath)
// Connect to bootstrap relays // Connect to bootstrap relays
@@ -66,19 +82,18 @@ class NostrForegroundService : Service() {
} }
) )
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}") Log.e("Coop", "Failed to start Nostr", e)
} }
} }
return START_STICKY return START_STICKY
} }
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() { private fun createNotificationChannel() {
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
val serviceChannel = NotificationChannel( val serviceChannel = NotificationChannel(
"nostr_service_silent", "nostr_service",
"Nostr Background Status", "Nostr Background Status",
NotificationManager.IMPORTANCE_MIN NotificationManager.IMPORTANCE_MIN
).apply { ).apply {
@@ -127,7 +142,7 @@ class NostrForegroundService : Service() {
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val notification = NotificationCompat.Builder(this, "nostr_messages") val notification = NotificationCompat.Builder(this, "nostr_messages")
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle("You received a new message") .setContentTitle("You received a new message")

View File

@@ -1,6 +1,7 @@
package su.reya.coop.screens package su.reya.coop.screens
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@@ -38,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable 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.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -47,14 +49,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -66,36 +70,42 @@ import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@Composable @Composable
fun ChatScreen( fun ChatScreen(id: Long) {
id: Long,
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState() // Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsState() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } } val room by remember(id) {
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
// Show empty screen
if (room == null) { if (room == null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
LoadingIndicator() Text(
text = "Chat room not found",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
} }
return return
} }
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
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) } var newOtherMessages by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
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() }
} }
@@ -152,8 +162,8 @@ fun ChatScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey -> room!!.members.firstOrNull()?.let { pubkey ->
navController.navigate(Screen.Profile(pubkey.toBech32())) navigator.navigate(Screen.Profile(pubkey.toBech32()))
} }
} }
) { ) {
@@ -185,7 +195,7 @@ fun ChatScreen(
} }
} }
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -238,10 +248,15 @@ fun ChatScreen(
.fillMaxWidth(), .fillMaxWidth(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text( Text(
text = "No messages yet", text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(

View File

@@ -1,6 +1,12 @@
package su.reya.coop.screens package su.reya.coop.screens
import android.Manifest
import android.content.ClipData import android.content.ClipData
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -9,12 +15,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -36,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TooltipDefaults
@@ -61,8 +71,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
@@ -70,8 +84,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -83,11 +98,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen() {
onOpenChat: (Long) -> Unit, val context = LocalContext.current
onNewChat: () -> Unit, val navigator = LocalNavigator.current
) { val qrScanResult = LocalScanResult.current
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -95,45 +109,53 @@ fun HomeScreen(
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
val userProfile by currentUserProfile.collectAsState(initial = null) val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle var isNotificationEnabled by remember {
val qrResult by savedStateHandle mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
if (qrResult == null) {
viewModel.getChatRooms()
}
} }
LaunchedEffect(qrResult) { val permissionLauncher = rememberLauncherForActivityResult(
qrResult?.let { result -> ActivityResultContracts.RequestPermission()
) { _ ->
// State will be updated by LifecycleResumeEffect
}
LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
LaunchedEffect(Unit) {
viewModel.getChatRooms()
}
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching { PublicKey.parse(result) } runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> .onSuccess { pubkey ->
try { try {
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
} }
} }
.onFailure { e -> println("Failed to parse QR: ${e.message}") } .onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -153,7 +175,7 @@ fun HomeScreen(
}, },
actions = { actions = {
// QR Scanner // QR Scanner
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"
@@ -184,7 +206,7 @@ fun HomeScreen(
state = rememberTooltipState(), state = rememberTooltipState(),
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewChat, onClick = { navigator.navigate(Screen.NewChat) },
expanded = expandedFab, expanded = expandedFab,
icon = { icon = {
Icon( Icon(
@@ -197,161 +219,229 @@ fun HomeScreen(
} }
}, },
content = { innerPadding -> content = { innerPadding ->
Surface( Column(
modifier = Modifier modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
.fillMaxSize() verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
PullToRefreshBox( if (!isNotificationEnabled && !isBannerDismissed) {
modifier = Modifier.fillMaxSize(), Surface(
isRefreshing = isRefreshing, modifier = Modifier
state = pullToRefreshState, .fillMaxWidth()
onRefresh = { .padding(horizontal = 16.dp),
scope.launch { shape = RoundedCornerShape(24.dp),
isRefreshing = true color = MaterialTheme.colorScheme.secondaryContainer,
viewModel.refreshChatRooms() ) {
isRefreshing = false Column(
} modifier = Modifier
}, .fillMaxWidth()
indicator = { .padding(16.dp),
PullToRefreshDefaults.LoadingIndicator( verticalArrangement = Arrangement.spacedBy(16.dp),
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = "No chats yet", text = "Get message notifications",
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.SemiBold color = MaterialTheme.colorScheme.onSecondaryFixed,
),
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Your conversations will appear here.", text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
} Row(
} else { horizontalArrangement = Arrangement.spacedBy(8.dp),
LazyColumn( ) {
state = listState, TextButton(
modifier = Modifier.fillMaxSize() onClick = { viewModel.dismissNotificationBanner() },
) { modifier = Modifier.weight(1f),
items(chatRooms.toList(), key = { it.id }) { room -> ) {
ChatRoom( Text(text = "Maybe later")
room = room, }
onClick = { onOpenChat(room.id) } Button(
) onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
// For older versions, navigate the user directly to App Notification Settings
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(
Settings.EXTRA_APP_PACKAGE,
context.packageName
)
}
context.startActivity(intent)
}
},
modifier = Modifier.weight(1f),
) {
Text(text = "Turn on")
}
} }
} }
} }
} }
Surface(
if (showBottomSheet) { modifier = Modifier.fillMaxSize(),
ModalBottomSheet( color = MaterialTheme.colorScheme.surface,
onDismissRequest = { showBottomSheet = false }, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
sheetState = sheetState, ) {
) { PullToRefreshBox(
val pubkey = viewModel.currentUser() modifier = Modifier.fillMaxSize(),
val shortPubkey = pubkey?.short() ?: "Not available" isRefreshing = isRefreshing,
state = pullToRefreshState,
val userName = onRefresh = {
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch { scope.launch {
sheetState.hide() isRefreshing = true
showBottomSheet = false viewModel.refreshChatRooms()
action() isRefreshing = false
} }
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
} }
) {
Column( if (!isPartialProcessedGiftWrap) {
modifier = Modifier Box(
.padding(16.dp) modifier = Modifier.fillMaxSize(),
.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( LoadingIndicator()
modifier = Modifier }
.size(84.dp) } else if (chatRooms.isEmpty()) {
.clip(MaterialShapes.Cookie9Sided.toShape()), Box(
contentAlignment = Alignment.Center modifier = Modifier.fillMaxSize(),
) { contentAlignment = Alignment.Center
Avatar( ) {
picture = userProfile?.asRecord()?.picture, Column(
description = userProfile?.asRecord()?.displayName, horizontalAlignment = Alignment.CenterHorizontally,
shape = MaterialShapes.Cookie9Sided.toShape(), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName, text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
) )
} }
Spacer(modifier = Modifier.size(8.dp)) }
Row( } else {
verticalAlignment = Alignment.CenterVertically, LazyColumn(
) { state = listState,
OutlinedButton( modifier = Modifier.fillMaxSize()
onClick = { ) {
scope.launch { items(chatRooms.toList(), key = { it.id }) { room ->
pubkey?.let { ChatRoom(
val bech32 = it.toBech32() room = room,
val data = ClipData.newPlainText(bech32, bech32) onClick = { navigator.navigate(Screen.Chat(room.id)) }
clipboardManager.setClipEntry(ClipEntry(data)) )
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navController.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
} }
} }
Spacer(modifier = Modifier.size(16.dp)) }
BottomMenuList(onDismiss = dismissAndRun) }
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
modifier = Modifier
.imePadding()
.navigationBarsPadding(),
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
pubkey?.let {
val bech32 = it.toBech32()
val data =
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
} }
} }
} }
@@ -408,13 +498,14 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
fun BottomMenuList( fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit onDismiss: (suspend () -> Unit) -> Unit
) { ) {
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Relay Management" to { navController.navigate(Screen.Relay) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { },
"Spams & Blocks" to { }, "Spams & Blocks" to { },
"Contacts" to { }, "Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { } "Settings" to { }
) )

View File

@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -49,17 +48,19 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
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.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -68,40 +69,30 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ImportScreen( fun ImportScreen() {
isLoading: Boolean,
onBack: () -> Unit,
onSave: (secret: String) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) } var pubkey by remember { mutableStateOf<PublicKey?>(null) }
val metadata by remember(pubkey) {
if (pubkey != null) {
viewModel.getMetadata(pubkey!!)
} else {
MutableStateFlow(null)
}
}.collectAsState(null)
// Get metadata when pubkey changes
val metadata by remember(pubkey) {
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
val profile = metadata?.asRecord() val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture val picture = profile?.picture
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle LaunchedEffect(qrScanResult.content) {
val qrResult by savedStateHandle qrScanResult.content?.let { result ->
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(qrResult) {
qrResult?.let { result ->
runCatching { runCatching {
if (result.startsWith("nsec")) { if (result.startsWith("nsec")) {
Keys.parse(result) Keys.parse(result)
@@ -113,8 +104,9 @@ fun ImportScreen(
} }
.onSuccess { it -> secret = result } .onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") } .onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -133,7 +125,7 @@ fun ImportScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -141,7 +133,7 @@ fun ImportScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"
@@ -213,6 +205,7 @@ fun ImportScreen(
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxLines = 4, maxLines = 4,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -225,10 +218,10 @@ fun ImportScreen(
), ),
visualTransformation = PasswordVisualTransformation('*'), visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy( textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed, color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
), ),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary), cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) { Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) { if (secret.isEmpty()) {
@@ -250,24 +243,28 @@ fun ImportScreen(
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
if (pubkey == null) { scope.launch {
scope.launch { if (pubkey == null) {
viewModel.verifyIdentity(secret).let { pubkey = it } viewModel.verifyIdentity(secret).let { pubkey = it }
} else {
// Import the identity
viewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} }
} else {
onSave(secret)
} }
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading, enabled = secret.isNotBlank() && !isLoggedIn,
) { ) {
if (isLoading) { if (isLoggedIn) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(
text = if (pubkey == null) "Verify" else "Continue", text = if (pubkey == null) "Verify" else "Click again to Continue",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -19,13 +19,13 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@Composable @Composable
fun MyQrScreen( fun MyQrScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
@@ -41,7 +41,7 @@ fun MyQrScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -54,8 +54,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -63,11 +64,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewChatScreen( fun NewChatScreen() {
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet()) val contactList by viewModel.contactList.collectAsState(initial = emptySet())
@@ -76,12 +76,6 @@ fun NewChatScreen(
val selectedReceivers = remember { mutableStateListOf<PublicKey>() } val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(query) { LaunchedEffect(query) {
if (query.length >= 3) { if (query.length >= 3) {
delay(500) // 500ms debounce delay(500) // 500ms debounce
@@ -111,13 +105,19 @@ fun NewChatScreen(
} }
} }
LaunchedEffect(qrResult) { LaunchedEffect(qrScanResult.content) {
qrResult?.let { result -> qrScanResult.content?.let { result ->
// Verify the content
runCatching { PublicKey.parse(result) } runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> selectedReceivers.add(pubkey) } .onSuccess { pubkey ->
.onFailure { e -> println("Failed to parse QR: ${e.message}") } selectedReceivers.add(pubkey)
}
.onFailure { e ->
println("Failed to parse QR: ${e.message}")
}
// Clear the nav state // Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result") qrScanResult.clear()
} }
} }
@@ -136,7 +136,7 @@ fun NewChatScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -144,7 +144,7 @@ fun NewChatScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) { IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_scanner), painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner" contentDescription = "Scanner"
@@ -168,7 +168,7 @@ fun NewChatScreen(
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList()) val roomId = viewModel.createChatRoom(selectedReceivers.toList())
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
expanded = false, expanded = false,
icon = { icon = {
@@ -259,7 +259,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
}, },
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
@@ -270,7 +270,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} }
) )
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))

View File

@@ -1,273 +1,31 @@
package su.reya.coop.screens package su.reya.coop.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.setValue import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment import su.reya.coop.LocalNavigator
import androidx.compose.ui.Modifier import su.reya.coop.LocalNostrViewModel
import androidx.compose.ui.draw.clip import su.reya.coop.Screen
import androidx.compose.ui.graphics.SolidColor import su.reya.coop.shared.ProfileEditor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewIdentityScreen( fun NewIdentityScreen() {
isLoading: Boolean, val viewModel = LocalNostrViewModel.current
onBack: () -> Unit, val navigator = LocalNavigator.current
onSave: (name: String, bio: String?, picture: Uri?) -> Unit val scope = rememberCoroutineScope()
) { val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
var name by remember { mutableStateOf("") } ProfileEditor(
var bio by remember { mutableStateOf("") } title = "Create a new identity",
var picture by remember { mutableStateOf<Uri?>(null) } buttonLabel = "Continue",
isBusy = isLoggedIn,
val launcher = rememberLauncherForActivityResult( onBack = { navigator.goBack() },
contract = ActivityResultContracts.GetContent() onConfirm = { name, bio, bytes, type ->
) { uri: Uri? -> scope.launch {
picture = uri viewModel.createIdentity(name, bio, bytes, type)
} navigator.navigate(Screen.Home)
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Create a new identity",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape())
.clickable { launcher.launch("image/*") },
contentAlignment = Alignment.Center
) {
if (picture != null) {
AsyncImage(
model = picture,
contentDescription = "Profile picture",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxSize()
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Pick avatar",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
onSave(name, bio, picture)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
} }
} }
) )

View File

@@ -37,13 +37,17 @@ import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { fun OnboardingScreen() {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
val annotatedText = buildAnnotatedString { val annotatedText = buildAnnotatedString {
@@ -127,7 +131,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
) )
Spacer(modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.size(24.dp))
Button( Button(
onClick = onOpenNew, onClick = { navigator.navigate(Screen.NewIdentity) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
@@ -139,7 +143,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
OutlinedButton( OutlinedButton(
onClick = onOpenImport, onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),

View File

@@ -44,7 +44,7 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
@@ -54,15 +54,12 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen( fun ProfileScreen(pubkey: String) {
onBack: () -> Unit,
pubkey: String
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -88,7 +85,7 @@ fun ProfileScreen(
TopAppBar( TopAppBar(
title = { }, title = { },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -162,7 +159,7 @@ fun ProfileScreen(
scope.launch { scope.launch {
try { try {
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }
} }
@@ -179,7 +176,7 @@ fun ProfileScreen(
} }
Text( Text(
text = "Message", text = "Message",
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelMedium
) )
} }
Column( Column(

View File

@@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RelayScreen( fun RelayScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -80,7 +80,7 @@ fun RelayScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"

View File

@@ -30,21 +30,16 @@ import org.jetbrains.compose.resources.painterResource
import org.publicvalue.multiplatform.qrcode.CameraPosition import org.publicvalue.multiplatform.qrcode.CameraPosition
import org.publicvalue.multiplatform.qrcode.CodeType import org.publicvalue.multiplatform.qrcode.CodeType
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
import su.reya.coop.LocalNavController import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun ScanScreen( fun ScanScreen() {
onBack: () -> Unit val navigator = LocalNavigator.current
) {
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val qrScanResult = LocalScanResult.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
navController.popBackStack()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -57,7 +52,7 @@ fun ScanScreen(
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_arrow_back), painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back" contentDescription = "Back"
@@ -76,7 +71,8 @@ fun ScanScreen(
ScannerWithPermissions( ScannerWithPermissions(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onScanned = { onScanned = {
onResult(it) qrScanResult.content = it
navigator.goBack()
true true
}, },
types = listOf(CodeType.QR), types = listOf(CodeType.QR),

View File

@@ -0,0 +1,40 @@
package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor
@Composable
fun UpdateProfileScreen() {
val viewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val currentUser = viewModel.currentUser() ?: return
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
val isBusy by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val profile = metadata?.asRecord()
ProfileEditor(
title = "Update profile",
buttonLabel = "Save changes",
initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "",
initialPicture = profile?.picture,
isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
viewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
}
)
}

View File

@@ -0,0 +1,294 @@
package su.reya.coop.shared
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileEditor(
title: String,
buttonLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
val hasPicture = remember(picture) {
when (picture) {
null -> false
is String -> (picture as CharSequence).isNotBlank()
else -> true
}
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
picture = uri
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape())
.clickable { launcher.launch("image/*") },
contentAlignment = Alignment.Center
) {
if (hasPicture) {
AsyncImage(
model = picture,
contentDescription = "Profile picture",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
modifier = Modifier.fillMaxSize()
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Pick avatar",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.size(ButtonDefaults.MediumContainerHeight),
onClick = {
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
val bytes = withContext(Dispatchers.IO) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}
}
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
}
},
enabled = name.isNotBlank() && !isBusy
) {
if (isBusy) {
LoadingIndicator()
} else {
Text(
text = buttonLabel,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}

View File

@@ -9,25 +9,32 @@ import su.reya.coop.Room
import su.reya.coop.short import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> { fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!) // Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val memberFlows = members.map { viewModel.getMetadata(it) } val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.name?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}
return combine(memberFlows) { metadataArray ->
if (isGroup()) { if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() } val combined = names.joinToString(", ")
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName } val extraCount = members.size - names.size
var combined = names.joinToString(", ") if (extraCount > 0) "$combined, +$extraCount" else combined
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
} else { } else {
val profile = metadataArray.firstOrNull()?.asRecord() val name = names.first()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown" if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
} }
} }
} }
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> { fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null) val firstMember = members.firstOrNull() ?: return flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
} }

View File

@@ -1,14 +1,13 @@
[versions] [versions]
agp = "9.2.1" agp = "9.2.1"
android-compileSdk = "36" android-compileSdk = "37"
android-minSdk = "24" android-minSdk = "26"
android-targetSdk = "36" android-targetSdk = "37"
androidx-activity = "1.13.0" androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1" androidx-appcompat = "1.7.1"
androidx-core = "1.18.0" androidx-core = "1.18.0"
androidx-espresso = "3.7.0" androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-navigation = "2.9.8"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0" androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0" composeMultiplatform = "1.11.0"
@@ -17,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlinx-serialization = "1.11.0" kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07" material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0" ktor = "3.5.0"
[libraries] [libraries]
@@ -31,7 +31,6 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@@ -49,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -30,6 +30,7 @@ kotlin {
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3") implementation("su.reya:nostr-sdk-kmp:0.2.3")

View File

@@ -1,75 +0,0 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import rust.nostr.sdk.ConnectionMode
import rust.nostr.sdk.CustomWebSocketTransport
import rust.nostr.sdk.WebSocketAdapter
import rust.nostr.sdk.WebSocketAdapterWrapper
import rust.nostr.sdk.WebSocketMessage
class KtorWebSocketAdapter(
private val client: HttpClient,
private val session: DefaultClientWebSocketSession
) : WebSocketAdapter {
override suspend fun send(msg: WebSocketMessage) {
try {
when (msg) {
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
else -> {}
}
} catch (e: Exception) {
println("Attempted to send on a closed WebSocket: ${e.message}")
throw e
}
}
override suspend fun recv(): WebSocketMessage? {
return try {
when (val frame = session.incoming.receive()) {
is Frame.Text -> WebSocketMessage.Text(frame.readText())
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
else -> null
}
} catch (e: ClosedReceiveChannelException) {
null
} catch (e: Exception) {
throw e
}
}
override suspend fun closeConnection() {
session.cancel()
session.close()
}
}
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
override fun supportPing(): Boolean = false
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
try {
val session = httpClient.webSocketSession {
url(url)
}
val adapter = KtorWebSocketAdapter(httpClient, session)
return WebSocketAdapterWrapper(adapter)
} catch (e: Exception) {
throw e
}
}
}

View File

@@ -2,7 +2,6 @@ package su.reya.coop
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
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
@@ -39,6 +38,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMessageEnum import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayStatus
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.ReqTarget
@@ -56,9 +56,21 @@ import rust.nostr.sdk.giftWrapAsync
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://purplepag.es"
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
} }
class Nostr { class Nostr {
@@ -75,7 +87,6 @@ class Nostr {
private val isInitialized = MutableStateFlow(false) private val isInitialized = MutableStateFlow(false)
// Add these to the Nostr class
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
@@ -99,24 +110,26 @@ class Nostr {
suspend fun emitContactListUpdate(contacts: List<PublicKey>) = suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
_contactListUpdates.emit(contacts) _contactListUpdates.emit(contacts)
suspend fun init(dbPath: String) { suspend fun init(
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
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)
// Initialize the database and gossip instance
val lmdb = NostrDatabase.lmdb(dbPath) val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory() val gossip = NostrGossip.inMemory()
// Set the idle timeout for relays
val idleTimeout = Duration.parse("5m") val idleTimeout = Duration.parse("5m")
val httpClient = HttpClient {
install(WebSockets)
}
client = client =
ClientBuilder() ClientBuilder()
.signer(signer) .signer(signer)
.websocketTransport(CoopWebSocketClient(httpClient))
.database(lmdb) .database(lmdb)
.gossip(gossip) .gossip(gossip)
.gossipConfig( .gossipConfig(
@@ -142,24 +155,43 @@ class Nostr {
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
// Bootstrap relays NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse(url))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) }
client?.addRelay(RelayUrl.parse("wss://purplepag.es")) NostrManager.INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
)
}
// Connect to all bootstrap relays
client?.connect()
}
suspend fun reconnect() {
// Indexer relay for NIP-65 discovery NostrManager.ALL_RELAYS.forEach { url ->
client?.addRelay( try {
url = RelayUrl.parse("wss://indexer.coracle.social"), client?.relay(RelayUrl.parse(url)).let { relay ->
capabilities = RelayCapabilities.gossip() if (relay != null) {
) if (relay.status() != RelayStatus.CONNECTED) {
relay.connect()
// Connect to all bootstrap relays and wait for all connections to be established }
client?.connect(Duration.parse("2s")) }
}
} catch (e: Exception) {
println("Failed to reconnect relay: ${e.message}")
}
}
} }
suspend fun disconnect() { suspend fun disconnect() {
client?.shutdown() NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) {
println("Failed to disconnect relay: ${e.message}")
}
}
} }
suspend fun exit() { suspend fun exit() {
@@ -230,7 +262,7 @@ class Nostr {
client?.subscribe( client?.subscribe(
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "all-gift-wraps" id = "gift-wraps"
) )
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
@@ -295,7 +327,7 @@ class Nostr {
eoseTrackerJob?.cancel() eoseTrackerJob?.cancel()
// Start a new tracker // Start a new tracker
eoseTrackerJob = launch { eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose() onSubscriptionClose()
} }
@@ -314,7 +346,7 @@ class Nostr {
is RelayMessageEnum.EndOfStoredEvents -> { is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId val subscriptionId = message.subscriptionId
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") { if (subscriptionId == "gift-wraps") {
onSubscriptionClose() onSubscriptionClose()
} }
} }
@@ -368,7 +400,7 @@ class Nostr {
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!), Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()), Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("dm")) Tag.custom(TagKind.Unknown("k"), listOf("14"))
) )
// Set event kind // Set event kind
@@ -397,7 +429,6 @@ class Nostr {
// Try to unwrap the gift with each signer // Try to unwrap the gift with each signer
for (signer in signers) { for (signer in signers) {
try { try {
// TODO: custom unwrapping logic
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor() val rumor = gift.rumor()
// Save the rumor to the database // Save the rumor to the database
@@ -500,20 +531,67 @@ class Nostr {
setSigner(keys) setSigner(keys)
} }
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: String? = null
): Metadata {
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
val newRecord = record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).signAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
return newMetadata
} catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e)
}
}
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try {
val kind = Kind.fromStd(KindStandard.METADATA);
val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content())
} catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}")
null
}
}
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> { suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try { try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u) val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)
val results = mutableMapOf<PublicKey, Metadata>() val results = mutableMapOf<PublicKey, Metadata>()
events?.toVec()?.forEach { event -> events?.toVec()?.forEach { event ->
val metadata = Metadata.fromJson(event.content()) try {
results[event.author()] = metadata val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
} }
return results return results
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e) println("Failed to get all cache metadata: ${e.message}")
return emptyMap()
} }
} }
@@ -533,7 +611,6 @@ class Nostr {
ReqTarget.manual( ReqTarget.manual(
mapOf( mapOf(
RelayUrl.parse("wss://purplepag.es") to listOf(filter), RelayUrl.parse("wss://purplepag.es") to listOf(filter),
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
RelayUrl.parse("wss://relay.primal.net") to listOf(filter), RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
) )
) )
@@ -596,7 +673,7 @@ class Nostr {
val kTag = SingleLetterTag.lowercase(Alphabet.K) val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user // Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm") val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)
// Collect rooms // Collect rooms
@@ -641,6 +718,8 @@ class Nostr {
return events return events
?.toVec() ?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) } ?.map { UnsignedEvent.fromJson(it.content()) }
// Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
@@ -694,7 +773,7 @@ class Nostr {
} }
suspend fun sendMessage( suspend fun sendMessage(
to: List<PublicKey>, to: Set<PublicKey>,
content: String, content: String,
subject: String? = null, subject: String? = null,
replies: List<EventId> = emptyList(), replies: List<EventId> = emptyList(),
@@ -720,17 +799,16 @@ class Nostr {
// Add public key tags for each recipient // Add public key tags for each recipient
to.forEach { pubkey -> to.forEach { pubkey ->
if (pubkey != currentUser) { tags.add(Tag.publicKey(pubkey))
tags.add(Tag.publicKey(pubkey))
}
} }
for (receiver in listOf(currentUser) + to) { for (receiver in setOf(currentUser) + to) {
// Construct the rumor event // Construct the rumor event
// NEVER SIGN this event with the current user signer // NEVER SIGN this event with the current user signer
val rumor = EventBuilder val rumor = EventBuilder
.privateMsgRumor(receiver = receiver, message = content) .privateMsgRumor(receiver = receiver, message = content)
.tags(tags) .tags(tags)
.allowSelfTagging()
.build(currentUser) .build(currentUser)
// Ensure the event ID is set // Ensure the event ID is set
.ensureId() .ensureId()
@@ -807,13 +885,12 @@ class Nostr {
val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u) val filter = Filter().kinds(kinds).search(query).limit(10u)
val target = val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents( val stream = client?.streamEvents(
target = target, target = target,
id = "search", id = "search",
timeout = Duration.parse("4s"), timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose policy = ReqExitPolicy.ExitOnEose
) )

View File

@@ -1,12 +1,15 @@
package su.reya.coop package su.reya.coop
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,6 +22,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
@@ -33,23 +37,21 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class NostrViewModel( class NostrViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage private val secretStore: SecretStorage
) : ViewModel() { ) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
private val _signerRequired = MutableStateFlow<Boolean?>(null) private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.asStateFlow() val signerRequired = _signerRequired.asStateFlow()
private val _isCreating = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow() val isLoggedIn = _isLoggedIn.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false) private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -57,10 +59,16 @@ class NostrViewModel(
private val _isRelayListEmpty = MutableStateFlow(false) private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow() val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap()) private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow() val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED) private val _errorEvents = Channel<String>(Channel.BUFFERED)
@@ -71,25 +79,43 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
// Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) {
_signerRequired.value = false
}
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker) // Check local stored secret (secret key or bunker)
login() login()
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list // Observe the signer state and verify the relay list
observeSignerAndCheckRelays() observeSignerAndCheckRelays()
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
}
// Observe new events from the Nostr client fun bindLifecycle(lifecycle: Lifecycle) {
runObserver() viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Wait and merge metadata requests into a single batch coroutineScope {
runMetadataBatching() launch { refreshChatRooms() }
launch { runObserver() }
launch { runMetadataBatching() }
}
}
}
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
// Ensure all relays are disconnect
// Disconnect to all bootstrap relays
viewModelScope.launch { viewModelScope.launch {
withContext(NonCancellable) { withContext(NonCancellable) {
nostr.disconnect() nostr.disconnect()
@@ -100,85 +126,110 @@ class NostrViewModel(
private fun showError(message: String) { private fun showError(message: String) {
viewModelScope.launch { viewModelScope.launch {
_errorEvents.send(message) _errorEvents.send(message)
if (isCreating.value) _isCreating.value = false
} }
} }
private fun runObserver() { private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch { viewModelScope.launch {
// Observe new messages _isNotificationBannerDismissed.value =
launch { secretStore.get("notification_banner_dismissed") == "true"
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
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)
}
}
// 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() { private fun reconnect() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
nostr.reconnect()
}
}
val batch = mutableSetOf<PublicKey>() private fun processIncomingEvent(event: UnsignedEvent) {
val timeout = 500L // 500ms timeout for batching val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
while (true) { if (existingRoom == null) {
val firstKey = metadataRequestChannel.receive() nostr.signer.currentUser?.let { user ->
batch.add(firstKey) val newRoom = Room.new(event, user)
val lastFlushTime = Clock.System.now().toEpochMilliseconds() _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
}
while (batch.isNotEmpty()) { private suspend fun runObserver() = coroutineScope {
val nextKey = withTimeoutOrNull(timeout) { // Observe new messages
metadataRequestChannel.receive() launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
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)
}
if (nextKey != null) { _newEvents.emit(event)
batch.add(nextKey) }
} }
val now = Clock.System.now().toEpochMilliseconds() // Observe contact list updates
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { launch {
val keysToRequest = batch.toList() nostr.contactListUpdates.collect { contacts ->
batch.clear() _contactList.value = contacts.toSet()
}
}
nostr.fetchMetadataBatch(keysToRequest) // Observe metadata updates
} launch {
nostr.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
}
}
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the key if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest)
} }
} }
} }
@@ -201,34 +252,28 @@ class NostrViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
// Get user's signer secret try {
val secret = secretStore.get("user_signer") val secret = withTimeoutOrNull(3.seconds) {
secretStore.get("user_signer")
// If no secret is found, show onboarding screen
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)
nostr.setSigner(keys)
} 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, timeout, opts = null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
} }
} else {
throw IllegalArgumentException("Invalid secret format: $secret") if (secret == null) {
_signerRequired.value = true
return@launch
}
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_signerRequired.value = false
}.onFailure { e ->
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_signerRequired.value = true
} }
} }
} }
@@ -242,7 +287,7 @@ class NostrViewModel(
// Get chat rooms // Get chat rooms
val rooms = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) { if (rooms.isNotEmpty()) {
_chatRooms.value = rooms mergeChatRooms(rooms)
_isPartialProcessedGiftWrap.value = true _isPartialProcessedGiftWrap.value = true
} }
@@ -250,7 +295,7 @@ class NostrViewModel(
nostr.getUserMetadata() nostr.getUserMetadata()
// Small delay to ensure all relays are connected // Small delay to ensure all relays are connected
delay(3000) delay(3000.milliseconds)
// Check if the relay list is empty // Check if the relay list is empty
val relays = nostr.getMsgRelays(pubkey) val relays = nostr.getMsgRelays(pubkey)
@@ -261,7 +306,7 @@ class NostrViewModel(
break break
} }
delay(500) delay(500.milliseconds)
} }
} }
} }
@@ -298,6 +343,13 @@ class NostrViewModel(
} }
} }
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true")
_isNotificationBannerDismissed.value = true
}
}
fun dismissRelayWarning() { fun dismissRelayWarning() {
_isRelayListEmpty.value = false _isRelayListEmpty.value = false
} }
@@ -317,101 +369,119 @@ class NostrViewModel(
return keys return keys
} }
fun createIdentity( private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds // or Duration.parse("50s")
NostrConnect(uri = bunker, appKeys, timeout, null)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try {
// Upload picture to Blossom
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_isLoggedIn.value = true
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata)
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_isLoggedIn.value = false
}
}
suspend fun createIdentity(
name: String, name: String,
bio: String?, bio: String?,
picture: ByteArray?, picture: ByteArray?,
contentType: String? = null contentType: String? = null
) { ) {
viewModelScope.launch { _isLoggedIn.value = true
try { try {
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
var avatarUrl = "" val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Set loading state // Create identity
_isCreating.value = true nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Upload picture to Blossom // Save secret to the secret storage
if (picture != null) { secretStore.set("user_signer", secret)
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload( // Set an empty secret state
file = picture, _signerRequired.value = false
contentType = contentType, } catch (e: Exception) {
signer = keys showError("Error: ${e.message}")
) } finally {
_isLoggedIn.value = false
avatarUrl = descriptor?.url ?: ""
}
// Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Save secret to the secret storage
secretStore.set("user_signer", secret)
// Set an empty secret state
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} }
} }
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) { try {
val keys = Keys.parse(secret) val signer = createSigner(secret)
return keys.publicKey() if (secret.startsWith("bunker://")) {
} else if (secret.startsWith("bunker://")) { showError("Please approve the connection.")
val appKeys = getOrInitAppKeys() }
val bunker = NostrConnectUri.parse(secret) return signer.getPublicKeyAsync()
val timeout = Duration.parse("50s") // 50 seconds timeout } catch (e: Exception) {
val remote = NostrConnect(uri = bunker, appKeys, timeout, null) showError("Error: ${e.message}")
return null
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
} }
} }
fun importIdentity(secret: String) { suspend fun importIdentity(secret: String) {
viewModelScope.launch { _isLoggedIn.value = true
if (secret.startsWith("nsec1")) { try {
val keys = Keys.parse(secret) val signer = createSigner(secret)
nostr.setSigner(keys) nostr.setSigner(signer)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state } catch (e: Exception) {
_signerRequired.value = false showError("Error: ${e.message}")
} else if (secret.startsWith("bunker://")) { } finally {
try { _signerRequired.value = false
val appKeys = getOrInitAppKeys() _isLoggedIn.value = false
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
nostr.setSigner(remote)
secretStore.set("user_signer", secret)
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
}
} }
} }
@@ -469,7 +539,7 @@ class NostrViewModel(
// Update the chat rooms state // Update the chat rooms state
_chatRooms.update { currentRooms -> _chatRooms.update { currentRooms ->
currentRooms + room (currentRooms + room).sortedDescending().toSet()
} }
return room.id return room.id
@@ -478,26 +548,33 @@ class NostrViewModel(
} }
} }
fun getChatRoom(id: Long): Room { fun getChatRoom(id: Long): Room? {
return chatRooms.value.firstOrNull { it.id == id } return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found") }
private fun mergeChatRooms(rooms: Set<Room>) {
_chatRooms.update { currentRooms ->
val merged = currentRooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database
rooms.forEach { room ->
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
merged.values.sortedDescending().toSet()
}
} }
fun getChatRooms() { fun getChatRooms() {
viewModelScope.launch { viewModelScope.launch {
val rooms = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
_chatRooms.update { currentRooms -> mergeChatRooms(rooms)
val virtualRooms = currentRooms.filter { local ->
rooms.none { db -> db.id == local.id }
}
rooms + virtualRooms
}
} }
} }
suspend fun refreshChatRooms() { suspend fun refreshChatRooms() {
try { try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet() val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -514,14 +591,19 @@ class NostrViewModel(
} }
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> { suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
val room = getChatRoom(roomId) try {
val members = room.members val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
return runCatching { return runCatching {
nostr.chatRoomConnect(members.toList()) nostr.chatRoomConnect(members.toList())
}.getOrElse { e -> }.getOrElse { e ->
showError("Error: ${e.message}")
members.associateWith { emptyList() }
}
} catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
members.associateWith { emptyList<RelayUrl>() } return emptyMap()
} }
} }
@@ -531,9 +613,9 @@ class NostrViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
val room = getChatRoom(roomId) val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.sendMessage( nostr.sendMessage(
to = room.members.toList(), to = room.members,
content = message, content = message,
subject = room.subject, subject = room.subject,
replies = replies, replies = replies,

View File

@@ -43,8 +43,11 @@ data class Room(
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 // Also remove the user's public key from the list, current user is always a member
pubkeys.remove(userPubkey) if (pubkeys.size > 1 && pubkeys.contains(userPubkey)) {
pubkeys.remove(userPubkey)
}
// Create a new Room instance // Create a new Room instance
return Room( return Room(

View File

@@ -2,31 +2,42 @@ package su.reya.coop
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.seconds
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
private val mutex = Mutex() private val mutex = Mutex()
@Volatile
private var signer: AsyncNostrSigner = initialSigner private var signer: AsyncNostrSigner = initialSigner
@Volatile
var currentUser: PublicKey? = null var currentUser: PublicKey? = null
private set private set
/** /**
* Get the current signer. * Get the current signer.
*/ */
suspend fun get(): AsyncNostrSigner = mutex.withLock { fun get(): AsyncNostrSigner = signer
signer
}
/** /**
* Switch to a new signer. * Switch to a new signer.
*/ */
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
val pubkey = try {
withTimeoutOrNull(20.seconds) {
newSigner.getPublicKeyAsync()
}
} catch (e: Exception) {
throw IllegalStateException("Failed to get public key from signer", e)
}
signer = newSigner signer = newSigner
currentUser = newSigner.getPublicKeyAsync() currentUser = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {