4 Commits

Author SHA1 Message Date
50b7f7a3f3 chore: bump version 2026-06-07 07:37:30 +07:00
a65aa70a55 chore: optimize the battery usage (#16)
Reviewed-on: #16
2026-06-07 00:35:46 +00:00
74a37320fe fix: occasional splash screen hang (#15)
Reviewed-on: #15
2026-06-06 07:30:47 +00:00
b8b3b83952 feat: add update profile screen (#14)
Reviewed-on: #14
2026-06-06 05:50:32 +00:00
15 changed files with 676 additions and 444 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.6" versionName = "0.1.7"
} }
packaging { packaging {
resources { resources {

View File

@@ -2,6 +2,7 @@ package su.reya.coop
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -31,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@@ -64,6 +65,7 @@ import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -92,8 +94,8 @@ fun App(viewModel: NostrViewModel) {
val navigator = remember(backStack) { Navigator(backStack) } val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsState(initial = null) val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -104,7 +106,7 @@ fun App(viewModel: NostrViewModel) {
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context context
) )
@@ -203,6 +205,9 @@ fun App(viewModel: NostrViewModel) {
entry<Screen.Profile> { key -> entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey) ProfileScreen(pubkey = key.pubkey)
} }
entry<Screen.UpdateProfile> {
UpdateProfileScreen()
}
entry<Screen.Scan> { entry<Screen.Scan> {
ScanScreen() ScanScreen()
} }

View File

@@ -1,13 +1,13 @@
package su.reya.coop package su.reya.coop
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore import su.reya.coop.coop.storage.SecretStore
@@ -50,18 +50,16 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java) val serviceIntent = Intent(this, NostrForegroundService::class.java)
startForegroundService(serviceIntent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
// Keep the splash screen visible until the signer check is complete // Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition { splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null viewModel.signerRequired.value == null
} }
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent { setContent {
App(viewModel = viewModel) App(viewModel = viewModel)
} }

View File

@@ -29,6 +29,9 @@ sealed interface Screen : NavKey {
@Serializable @Serializable
data class Profile(val pubkey: String) : Screen data class Profile(val pubkey: String) : Screen
@Serializable
data object UpdateProfile : Screen
@Serializable @Serializable
data object NewChat : Screen data object NewChat : Screen

View File

@@ -10,13 +10,13 @@ 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 android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,6 +25,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 by lazy { NostrManager.instance } private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -46,10 +47,12 @@ class NostrForegroundService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceScope.launch { if (notificationJob?.isActive == true) return START_STICKY
notificationJob = serviceScope.launch {
try { try {
Log.d("Coop", "Starting Nostr in background") Log.d("Coop", "Starting Nostr in background")
// Create a database directory // Create a database directory
val dbDir = File(filesDir, "nostr") val dbDir = File(filesDir, "nostr")
dbDir.mkdirs() dbDir.mkdirs()
@@ -82,10 +85,10 @@ class NostrForegroundService : Service() {
Log.e("Coop", "Failed to start Nostr", e) Log.e("Coop", "Failed to start Nostr", e)
} }
} }
return START_STICKY return START_STICKY
} }
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() { private fun createNotificationChannel() {
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -50,6 +51,7 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
@@ -73,28 +75,37 @@ fun ChatScreen(id: Long) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState() // Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsState() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } } val room by remember(id) {
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
// Show empty screen
if (room == null) { if (room == null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
LoadingIndicator() Text(
text = "Chat room not found",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
} }
return return
} }
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) { val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() } messages.groupBy { it.createdAt().formatAsGroupHeader() }
} }
@@ -151,7 +162,7 @@ fun ChatScreen(id: Long) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey -> room!!.members.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32())) navigator.navigate(Screen.Profile(pubkey.toBech32()))
} }
} }

View File

@@ -76,6 +76,7 @@ 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.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
@@ -108,8 +109,8 @@ fun HomeScreen() {
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
val userProfile by currentUserProfile.collectAsState(initial = null) val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
@@ -501,9 +502,10 @@ fun BottomMenuList(
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Relay Management" to { navigator.navigate(Screen.Relay) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { },
"Spams & Blocks" to { }, "Spams & Blocks" to { },
"Contacts" to { }, "Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { } "Settings" to { }
) )

View File

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

View File

@@ -176,7 +176,7 @@ fun ProfileScreen(pubkey: String) {
} }
Text( Text(
text = "Message", text = "Message",
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelMedium
) )
} }
Column( Column(

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMessageEnum import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayStatus
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget import rust.nostr.sdk.ReqTarget
@@ -59,6 +60,17 @@ import kotlin.time.Duration.Companion.milliseconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://purplepag.es"
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
} }
class Nostr { class Nostr {
@@ -75,7 +87,6 @@ class Nostr {
private val isInitialized = MutableStateFlow(false) private val isInitialized = MutableStateFlow(false)
// Add these to the Nostr class
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
@@ -99,12 +110,15 @@ class Nostr {
suspend fun emitContactListUpdate(contacts: List<PublicKey>) = suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
_contactListUpdates.emit(contacts) _contactListUpdates.emit(contacts)
suspend fun init(dbPath: String) { suspend fun init(
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
try { try {
if (isInitialized.value) return if (isInitialized.value) return
// Initialize the logger for nostr client // Initialize the logger for nostr client
initLogger(LogLevel.DEBUG) initLogger(logLevel)
// Initialize the database and gossip instance // Initialize the database and gossip instance
val lmdb = NostrDatabase.lmdb(dbPath) val lmdb = NostrDatabase.lmdb(dbPath)
@@ -141,24 +155,43 @@ class Nostr {
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
// Bootstrap relays NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse(url))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) }
client?.addRelay(RelayUrl.parse("wss://purplepag.es")) NostrManager.INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
)
}
// Connect to all bootstrap relays
client?.connect()
}
suspend fun reconnect() {
// Indexer relay for NIP-65 discovery NostrManager.ALL_RELAYS.forEach { url ->
client?.addRelay( try {
url = RelayUrl.parse("wss://indexer.coracle.social"), client?.relay(RelayUrl.parse(url)).let { relay ->
capabilities = RelayCapabilities.gossip() if (relay != null) {
) if (relay.status() != RelayStatus.CONNECTED) {
relay.connect()
// Connect to all bootstrap relays and wait for all connections to be established }
client?.connect(Duration.parse("2s")) }
}
} catch (e: Exception) {
println("Failed to reconnect relay: ${e.message}")
}
}
} }
suspend fun disconnect() { suspend fun disconnect() {
client?.shutdown() NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) {
println("Failed to disconnect relay: ${e.message}")
}
}
} }
suspend fun exit() { suspend fun exit() {
@@ -498,6 +531,48 @@ class Nostr {
setSigner(keys) setSigner(keys)
} }
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: String? = null
): Metadata {
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
val newRecord = record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).signAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
return newMetadata
} catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e)
}
}
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try {
val kind = Kind.fromStd(KindStandard.METADATA);
val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content())
} catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}")
null
}
}
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> { suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try { try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u) val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
@@ -536,7 +611,6 @@ class Nostr {
ReqTarget.manual( ReqTarget.manual(
mapOf( mapOf(
RelayUrl.parse("wss://purplepag.es") to listOf(filter), RelayUrl.parse("wss://purplepag.es") to listOf(filter),
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
RelayUrl.parse("wss://relay.primal.net") to listOf(filter), RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
) )
) )
@@ -811,13 +885,12 @@ class Nostr {
val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u) val filter = Filter().kinds(kinds).search(query).limit(10u)
val target = val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents( val stream = client?.streamEvents(
target = target, target = target,
id = "search", id = "search",
timeout = Duration.parse("4s"), timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose policy = ReqExitPolicy.ExitOnEose
) )

View File

@@ -1,12 +1,15 @@
package su.reya.coop package su.reya.coop
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,18 +53,18 @@ class NostrViewModel(
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.asStateFlow() val isLoggedIn = _isLoggedIn.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false) private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false) private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow() val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
@@ -76,28 +79,43 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
init { init {
// Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) {
_signerRequired.value = false
}
// Check if the notification banner has been dismissed // Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus() checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker) // Check local stored secret (secret key or bunker)
login() login()
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list // Observe the signer state and verify the relay list
observeSignerAndCheckRelays() observeSignerAndCheckRelays()
// Get all local stored metadata // Get all local stored metadata
getCacheMetadata() getCacheMetadata()
}
// Observe new events from the Nostr client fun bindLifecycle(lifecycle: Lifecycle) {
runObserver() viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Wait and merge metadata requests into a single batch coroutineScope {
runMetadataBatching() launch { refreshChatRooms() }
launch { runObserver() }
launch { runMetadataBatching() }
}
}
}
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
// Ensure all relays are disconnect
// Disconnect to all bootstrap relays
viewModelScope.launch { viewModelScope.launch {
withContext(NonCancellable) { withContext(NonCancellable) {
nostr.disconnect() nostr.disconnect()
@@ -118,81 +136,100 @@ class NostrViewModel(
} }
} }
private fun runObserver() { private fun reconnect() {
viewModelScope.launch { viewModelScope.launch {
// Observe new messages nostr.waitUntilInitialized()
launch { nostr.reconnect()
nostr.newEvents.collect { event -> }
val roomId = event.roomId() }
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) { private fun processIncomingEvent(event: UnsignedEvent) {
val currentUser = nostr.signer.currentUser val roomId = event.roomId()
if (currentUser != null) { val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() } if (existingRoom == null) {
} nostr.signer.currentUser?.let { user ->
} else { val newRoom = Room.new(event, user)
updateRoomList(roomId, event) _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
}
private suspend fun runObserver() = coroutineScope {
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.currentUser
if (currentUser != null) {
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
} }
} else {
_newEvents.emit(event) updateRoomList(roomId, event)
} }
_newEvents.emit(event)
} }
}
// Observe metadata updates // Observe contact list updates
launch { launch {
nostr.metadataUpdates.collect { (pubkey, metadata) -> nostr.contactListUpdates.collect { contacts ->
updateMetadata(pubkey, metadata) _contactList.value = contacts.toSet()
}
} }
}
// Observe contact list updates // Observe metadata updates
launch { launch {
nostr.contactListUpdates.collect { contacts -> nostr.metadataUpdates.collect { (pubkey, metadata) ->
_contactList.value = contacts.toSet() updateMetadata(pubkey, metadata)
}
} }
}
// Observes subscription close // Observes subscription close
launch { launch {
nostr.subscriptionClosed.collect { nostr.subscriptionClosed.collect {
getChatRooms() getChatRooms()
_isPartialProcessedGiftWrap.value = true _isPartialProcessedGiftWrap.value = true
}
} }
} }
} }
private fun runMetadataBatching() { private suspend fun runMetadataBatching() = coroutineScope {
viewModelScope.launch { // Wait until the client is ready
// Wait until the client is ready nostr.waitUntilInitialized()
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>() val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching val timeout = 500L // 500ms timeout for batching
while (true) { while (true) {
val firstKey = metadataRequestChannel.receive() val firstKey = metadataRequestChannel.receive()
batch.add(firstKey) batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds() val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) { while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout.milliseconds) { val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive() metadataRequestChannel.receive()
} }
if (nextKey != null) { // Only add the key if it's not null
batch.add(nextKey) if (nextKey != null) batch.add(nextKey)
}
val now = Clock.System.now().toEpochMilliseconds() // Get current time
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { val now = Clock.System.now().toEpochMilliseconds()
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest) // Check if the batch is full or timeout has passed
} if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest)
} }
} }
} }
@@ -216,7 +253,9 @@ class NostrViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
try { try {
val secret = secretStore.get("user_signer") val secret = withTimeoutOrNull(3.seconds) {
secretStore.get("user_signer")
}
if (secret == null) { if (secret == null) {
_signerRequired.value = true _signerRequired.value = true
@@ -344,6 +383,54 @@ class NostrViewModel(
} }
} }
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try {
// Upload picture to Blossom
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_isLoggedIn.value = true
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata)
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_isLoggedIn.value = false
}
}
suspend fun createIdentity( suspend fun createIdentity(
name: String, name: String,
bio: String?, bio: String?,
@@ -354,31 +441,7 @@ class NostrViewModel(
try { try {
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
var avatarUrl = "" val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// 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
)
avatarUrl = descriptor?.url ?: ""
}
// Create identity // Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
@@ -391,7 +454,7 @@ class NostrViewModel(
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} finally { } finally {
_isLoggedIn.value = true _isLoggedIn.value = false
} }
} }
@@ -485,9 +548,8 @@ class NostrViewModel(
} }
} }
fun getChatRoom(id: Long): Room { fun getChatRoom(id: Long): Room? {
return chatRooms.value.firstOrNull { it.id == id } return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found")
} }
private fun mergeChatRooms(rooms: Set<Room>) { private fun mergeChatRooms(rooms: Set<Room>) {
@@ -529,14 +591,19 @@ class NostrViewModel(
} }
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> { suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
val room = getChatRoom(roomId) try {
val members = room.members val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
return runCatching { return runCatching {
nostr.chatRoomConnect(members.toList()) nostr.chatRoomConnect(members.toList())
}.getOrElse { e -> }.getOrElse { e ->
showError("Error: ${e.message}")
members.associateWith { emptyList() }
}
} catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
members.associateWith { emptyList<RelayUrl>() } return emptyMap()
} }
} }
@@ -546,7 +613,7 @@ class NostrViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
try { try {
val room = getChatRoom(roomId) val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.sendMessage( nostr.sendMessage(
to = room.members, to = room.members,
content = message, content = message,

View File

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