7 Commits

Author SHA1 Message Date
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
23 changed files with 833 additions and 584 deletions

View File

@@ -19,12 +19,14 @@ kotlin {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
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("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.coil-kt.coil3:coil-compose: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.alexzhirkevich:qrose:1.1.2")
}
@@ -67,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.3"
versionName = "0.1.5"
}
packaging {
resources {

View File

@@ -19,6 +19,12 @@
android:supportsRtl="true"
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
android:name=".MainActivity"
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,9 @@
package su.reya.coop
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -29,8 +33,10 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,12 +45,14 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
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 su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen
@@ -65,26 +73,41 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("No Navigator provided")
}
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
error("No QrScanResult provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun App(viewModel: NostrViewModel) {
val context = LocalContext.current
val navController = rememberNavController()
val activity = context as? ComponentActivity
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.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
// Enabled the dynamic color scheme
val colorScheme = when {
// Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
}
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme()
@@ -92,12 +115,48 @@ fun App(viewModel: NostrViewModel) {
else -> expressiveLightColorScheme()
}
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) {
viewModel.errorEvents.collect { 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(
colorScheme = colorScheme,
typography = Typography(),
@@ -106,109 +165,55 @@ fun App(viewModel: NostrViewModel) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) {
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
val sheetState = rememberModalBottomSheetState()
LaunchedEffect(signerRequired) {
// Navigate to the home screen if the secret is already set
if (signerRequired == false) {
navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true }
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
(context as? Activity)?.finish()
}
},
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.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
if (isRelayListEmpty) {
@@ -267,3 +272,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

@@ -11,6 +11,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels {
@@ -23,6 +24,26 @@ class MainActivity : ComponentActivity() {
}
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()
enableEdgeToEdge()

View File

@@ -1,8 +1,25 @@
package su.reya.coop
import android.content.Intent
import androidx.navigation3.runtime.NavKey
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
data object Home : Screen

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
@@ -22,7 +24,7 @@ import java.io.File
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val nostr = NostrManager.instance
private val nostr by lazy { NostrManager.instance }
override fun onBind(intent: Intent?): IBinder? = null
@@ -30,17 +32,30 @@ class NostrForegroundService : Service() {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel()
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
}
val notification = createNotification()
startForeground(1, notification)
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 {
serviceScope.launch {
try {
Log.d("Coop", "Starting Nostr in background")
// Create a database directory
val dbDir = File(filesDir, "nostr")
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
// Connect to bootstrap relays
@@ -66,10 +81,9 @@ class NostrForegroundService : Service() {
}
)
} catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}")
Log.e("Coop", "Failed to start Nostr", e)
}
}
return START_STICKY
}
@@ -78,7 +92,7 @@ class NostrForegroundService : Service() {
val manager = getSystemService(NotificationManager::class.java)
val serviceChannel = NotificationChannel(
"nostr_service_silent",
"nostr_service",
"Nostr Background Status",
NotificationManager.IMPORTANCE_MIN
).apply {

View File

@@ -54,7 +54,7 @@ import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
@@ -66,12 +66,9 @@ import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable
fun ChatScreen(
id: Long,
onBack: () -> Unit,
) {
fun ChatScreen(id: Long) {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState()
@@ -153,7 +150,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey ->
navController.navigate(Screen.Profile(pubkey.toBech32()))
navigator.navigate(Screen.Profile(pubkey.toBech32()))
}
}
) {
@@ -185,7 +182,7 @@ fun ChatScreen(
}
}
) {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"

View File

@@ -1,6 +1,12 @@
package su.reya.coop.screens
import android.Manifest
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.layout.Arrangement
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.fillMaxSize
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -36,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
@@ -61,8 +71,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr
@@ -70,8 +83,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen
@@ -83,11 +97,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onOpenChat: (Long) -> Unit,
onNewChat: () -> Unit,
) {
val navController = LocalNavController.current
fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current
@@ -98,42 +111,50 @@ fun HomeScreen(
val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
if (qrResult == null) {
viewModel.getChatRooms()
}
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
}
LaunchedEffect(qrResult) {
qrResult?.let { result ->
val permissionLauncher = rememberLauncherForActivityResult(
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) }
.onSuccess { pubkey ->
try {
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
}
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
qrScanResult.clear()
}
}
@@ -153,7 +174,7 @@ fun HomeScreen(
},
actions = {
// QR Scanner
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
@@ -184,7 +205,7 @@ fun HomeScreen(
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = onNewChat,
onClick = { navigator.navigate(Screen.NewChat) },
expanded = expandedFab,
icon = {
Icon(
@@ -197,161 +218,229 @@ fun HomeScreen(
}
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
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
if (!isNotificationEnabled && !isBannerDismissed) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
text = "Get message notifications",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSecondaryFixed,
)
Text(
text = "Your conversations will appear here.",
text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { onOpenChat(room.id) }
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { viewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f),
) {
Text(text = "Maybe later")
}
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")
}
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
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 ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
text = "No chats yet",
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(
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 { navController.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
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,11 +497,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
) {
val navController = LocalNavController.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf(
"Relay Management" to { navController.navigate(Screen.Relay) },
"Relay Management" to { navigator.navigate(Screen.Relay) },
"Spams & Blocks" to { },
"Contacts" to { },
"Settings" to { }

View File

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

View File

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

View File

@@ -39,37 +39,48 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewIdentityScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) {
fun NewIdentityScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
@@ -88,7 +99,7 @@ fun NewIdentityScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
@@ -172,6 +183,7 @@ fun NewIdentityScreen(
BasicTextField(
value = name,
onValueChange = { name = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -183,10 +195,10 @@ fun NewIdentityScreen(
}
),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
@@ -214,6 +226,7 @@ fun NewIdentityScreen(
BasicTextField(
value = bio,
onValueChange = { bio = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
@@ -250,14 +263,35 @@ fun NewIdentityScreen(
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
onSave(name, bio, picture)
scope.launch {
try {
val imageBytes = withContext(Dispatchers.IO) {
picture?.let { uri ->
context.contentResolver.openInputStream(
uri
)?.use { input -> input.readBytes() }
}
}
val contentType =
picture?.let { context.contentResolver.getType(it) }
// Create the identity
viewModel.createIdentity(name, bio, imageBytes, contentType)
// Navigate to the home screen if successful
navigator.navigate(Screen.Home)
} catch (e: Exception) {
// Error is handled by viewModel.showError inside createIdentity
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading,
enabled = name.isNotBlank() && !isLoggedIn,
) {
if (isLoading) {
if (isLoggedIn) {
LoadingIndicator()
} else {
Text(

View File

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

View File

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

View File

@@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen(
onBack: () -> Unit
) {
fun RelayScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
@@ -80,7 +80,7 @@ fun RelayScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_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.CodeType
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
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun ScanScreen(
onBack: () -> Unit
) {
val navController = LocalNavController.current
fun ScanScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
navController.popBackStack()
}
val qrScanResult = LocalScanResult.current
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -57,7 +52,7 @@ fun ScanScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
@@ -76,7 +71,8 @@ fun ScanScreen(
ScannerWithPermissions(
modifier = Modifier.fillMaxSize(),
onScanned = {
onResult(it)
qrScanResult.content = it
navigator.goBack()
true
},
types = listOf(CodeType.QR),

View File

@@ -1,14 +1,13 @@
[versions]
agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
android-compileSdk = "37"
android-minSdk = "26"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-navigation = "2.9.8"
androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0"
@@ -17,6 +16,7 @@ junit = "4.13.2"
kotlin = "2.3.21"
kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0"
[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-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
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" }
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" }
@@ -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-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" }
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]
androidApplication = { id = "com.android.application", version.ref = "agp" }

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.call.body
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
@@ -106,17 +105,16 @@ class Nostr {
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
// Initialize the database and gossip instance
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
// Set the idle timeout for relays
val idleTimeout = Duration.parse("5m")
val httpClient = HttpClient {
install(WebSockets)
}
client =
ClientBuilder()
.signer(signer)
.websocketTransport(CoopWebSocketClient(httpClient))
.database(lmdb)
.gossip(gossip)
.gossipConfig(
@@ -596,7 +594,7 @@ class Nostr {
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "14")
val events = client?.database()?.query(filter)
// Collect rooms

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys
@@ -33,17 +34,20 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.asStateFlow()
private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
@@ -60,7 +64,7 @@ class NostrViewModel(
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
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()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
@@ -71,6 +75,9 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
@@ -100,7 +107,13 @@ class NostrViewModel(
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
if (isCreating.value) _isCreating.value = false
}
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
_isNotificationBannerDismissed.value =
secretStore.get("notification_banner_dismissed") == "true"
}
}
@@ -201,34 +214,26 @@ class NostrViewModel(
private fun login() {
viewModelScope.launch {
// Get user's signer secret
val secret = secretStore.get("user_signer")
try {
val secret = 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}")
if (secret == null) {
_signerRequired.value = true
return@launch
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
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
}
}
}
@@ -298,6 +303,13 @@ class NostrViewModel(
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true")
_isNotificationBannerDismissed.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
@@ -317,101 +329,95 @@ class NostrViewModel(
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")
}
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
_isLoggedIn.value = true
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
// Set loading state
_isCreating.value = true
// Upload picture to Blossom
if (picture != null) {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
// Upload picture to Blossom
if (picture != null) {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
)
}
)
val descriptor = blossom.upload(
file = picture,
contentType = contentType,
signer = keys
)
val descriptor = blossom.upload(
file = picture,
contentType = contentType,
signer = keys
)
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}")
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}")
} finally {
_isLoggedIn.value = true
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
return keys.publicKey()
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
try {
val signer = createSigner(secret)
if (secret.startsWith("bunker://")) {
showError("Please approve the connection.")
}
return signer.getPublicKeyAsync()
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
fun importIdentity(secret: String) {
viewModelScope.launch {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
secretStore.set("user_signer", secret)
// Set an empty secret state
_signerRequired.value = false
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, 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.")
}
suspend fun importIdentity(secret: String) {
_isLoggedIn.value = true
try {
val signer = createSigner(secret)
nostr.setSigner(signer)
secretStore.set("user_signer", secret)
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false
_isLoggedIn.value = false
}
}

View File

@@ -43,8 +43,11 @@ data class Room(
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys())
// Also remove the user's public key from the list, current user is always a member
pubkeys.remove(userPubkey)
if (pubkeys.size > 1 && pubkeys.contains(userPubkey)) {
pubkeys.remove(userPubkey)
}
// Create a new Room instance
return Room(