7 Commits

Author SHA1 Message Date
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
16 changed files with 614 additions and 374 deletions

View File

@@ -69,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.4" versionName = "0.1.6"
} }
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

@@ -189,25 +189,10 @@ fun App(viewModel: NostrViewModel) {
OnboardingScreen() OnboardingScreen()
} }
entry<Screen.Import> { entry<Screen.Import> {
ImportScreen( ImportScreen()
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
} }
entry<Screen.NewIdentity> { entry<Screen.NewIdentity> {
NewIdentityScreen( NewIdentityScreen()
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)
}
)
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
ChatScreen(id = key.id) ChatScreen(id = key.id)

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.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,6 +24,26 @@ 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()

View File

@@ -6,8 +6,10 @@ 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 android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -22,7 +24,7 @@ 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 }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -30,18 +32,28 @@ class NostrForegroundService : Service() {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onCreate() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { super.onCreate()
createNotificationChannel() createNotificationChannel()
val notification = createNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(1, notification)
}
} }
val notification = createNotification() override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(1, notification)
serviceScope.launch { 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
@@ -67,10 +79,9 @@ 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
} }

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
@@ -47,6 +48,7 @@ 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 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
@@ -235,10 +237,15 @@ fun ChatScreen(id: Long) {
.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,11 @@ 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 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
@@ -85,6 +98,7 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen() { fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
@@ -97,15 +111,32 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsState(initial = null) val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
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) }
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// State will be updated by LifecycleResumeEffect
}
LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.getChatRooms() viewModel.getChatRooms()
} }
@@ -187,10 +218,73 @@ fun HomeScreen() {
} }
}, },
content = { innerPadding -> content = { innerPadding ->
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (!isNotificationEnabled && !isBannerDismissed) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()), .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(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Get message notifications",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSecondaryFixed,
)
Text(
text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
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")
}
}
}
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
@@ -262,6 +356,9 @@ fun HomeScreen() {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
sheetState = sheetState, sheetState = sheetState,
modifier = Modifier
.imePadding()
.navigationBarsPadding(),
) { ) {
val pubkey = viewModel.currentUser() val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available" val shortPubkey = pubkey?.short() ?: "Not available"
@@ -319,7 +416,8 @@ fun HomeScreen() {
scope.launch { scope.launch {
pubkey?.let { pubkey?.let {
val bech32 = it.toBech32() val bech32 = it.toBech32()
val data = ClipData.newPlainText(bech32, bech32) val data =
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data)) clipboardManager.setClipEntry(ClipEntry(data))
} }
} }
@@ -346,6 +444,7 @@ fun HomeScreen() {
} }
} }
} }
}
}, },
) )
} }

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,10 +48,11 @@ 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
@@ -69,32 +69,28 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ImportScreen( fun ImportScreen() {
onSave: (secret: String) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.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) }
// Get metadata when pubkey changes
val metadata by remember(pubkey) { val metadata by remember(pubkey) {
if (pubkey != null) { pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
viewModel.getMetadata(pubkey!!) }.collectAsStateWithLifecycle(null)
} else {
MutableStateFlow(null)
}
}.collectAsState(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 isLoading by viewModel.isCreating.collectAsState()
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
@@ -209,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(
@@ -221,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()) {
@@ -246,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 { } else {
onSave(secret) // Import the identity
viewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} }
}
}, },
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

@@ -36,45 +36,50 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
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_plus 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 org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewIdentityScreen( fun NewIdentityScreen() {
onSave: (name: String, bio: String?, picture: Uri?) -> Unit val context = LocalContext.current
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) } var picture by remember { mutableStateOf<Uri?>(null) }
val isLoading by viewModel.isCreating.collectAsState() val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
@@ -178,6 +183,7 @@ fun NewIdentityScreen(
BasicTextField( BasicTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -189,10 +195,10 @@ fun NewIdentityScreen(
} }
), ),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy( textStyle = MaterialTheme.typography.headlineLargeEmphasized.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 (name.isEmpty()) { if (name.isEmpty()) {
@@ -220,6 +226,7 @@ fun NewIdentityScreen(
BasicTextField( BasicTextField(
value = bio, value = bio,
onValueChange = { bio = it }, onValueChange = { bio = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxLines = 3, maxLines = 3,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -256,14 +263,35 @@ fun NewIdentityScreen(
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading, enabled = name.isNotBlank() && !isLoggedIn,
) { ) {
if (isLoading) { if (isLoggedIn) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(

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,8 +1,8 @@
[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"

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
@@ -56,6 +55,7 @@ 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()
@@ -106,17 +106,16 @@ class Nostr {
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(LogLevel.DEBUG)
// 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(
@@ -230,7 +229,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 +294,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 +313,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 +367,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 +396,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
@@ -502,18 +500,23 @@ class Nostr {
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 ->
try {
val metadata = Metadata.fromJson(event.content()) val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata 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()
} }
} }
@@ -596,7 +599,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, "14") 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 +644,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 +699,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 +725,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()

View File

@@ -34,17 +34,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.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds 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()) private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow() val chatRooms = _chatRooms.asStateFlow()
@@ -61,7 +65,7 @@ class NostrViewModel(
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)
@@ -72,6 +76,9 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
// 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()
@@ -101,7 +108,13 @@ 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 checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
_isNotificationBannerDismissed.value =
secretStore.get("notification_banner_dismissed") == "true"
} }
} }
@@ -165,7 +178,7 @@ class NostrViewModel(
val lastFlushTime = Clock.System.now().toEpochMilliseconds() val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) { while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout) { val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive() metadataRequestChannel.receive()
} }
@@ -235,7 +248,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
} }
@@ -243,7 +256,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)
@@ -254,7 +267,7 @@ class NostrViewModel(
break break
} }
delay(500) delay(500.milliseconds)
} }
} }
} }
@@ -291,6 +304,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
} }
@@ -324,21 +344,18 @@ class NostrViewModel(
} }
} }
fun createIdentity( 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 = "" var avatarUrl = ""
// Set loading state
_isCreating.value = true
// Upload picture to Blossom // Upload picture to Blossom
if (picture != null) { if (picture != null) {
val blossom = BlossomClient( val blossom = BlossomClient(
@@ -373,31 +390,35 @@ class NostrViewModel(
_signerRequired.value = false _signerRequired.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} } finally {
_isLoggedIn.value = true
} }
} }
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun verifyIdentity(secret: String): PublicKey? {
return runCatching { try {
val signer = createSigner(secret) val signer = createSigner(secret)
if (secret.startsWith("bunker://")) { if (secret.startsWith("bunker://")) {
showError("Please approve the connection.") showError("Please approve the connection.")
} }
signer.getPublicKeyAsync() return signer.getPublicKeyAsync()
}.getOrNull() } catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
} }
fun importIdentity(secret: String) { suspend fun importIdentity(secret: String) {
viewModelScope.launch { _isLoggedIn.value = true
runCatching { try {
val signer = createSigner(secret) val signer = createSigner(secret)
nostr.setSigner(signer) nostr.setSigner(signer)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
}.onSuccess { } catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false _signerRequired.value = false
}.onFailure { e -> _isLoggedIn.value = false
showError(e.message ?: "Invalid Secret or Bunker URI")
}
} }
} }
@@ -455,7 +476,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
@@ -469,21 +490,29 @@ class NostrViewModel(
?: throw IllegalArgumentException("Room not found") ?: 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}")
} }
@@ -519,7 +548,7 @@ class NostrViewModel(
try { try {
val room = getChatRoom(roomId) val room = getChatRoom(roomId)
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,