Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50b7f7a3f3 | |||
| a65aa70a55 | |||
| 74a37320fe | |||
| b8b3b83952 | |||
| 5c2115e8b7 | |||
| ec337b8756 | |||
| fcae7d5825 |
@@ -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.5"
|
versionName = "0.1.7"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -35,9 +36,7 @@ class NostrForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
createNotificationChannel()
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
val notification = createNotification()
|
val notification = createNotification()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
@@ -48,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()
|
||||||
@@ -84,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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
@@ -38,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -47,7 +49,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
@@ -71,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() }
|
||||||
}
|
}
|
||||||
@@ -149,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()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,10 +248,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(
|
||||||
|
|||||||
@@ -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 { }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Message",
|
text = "Message",
|
||||||
style = MaterialTheme.typography.labelSmall
|
style = MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -55,9 +56,21 @@ import rust.nostr.sdk.giftWrapAsync
|
|||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
val instance = Nostr()
|
||||||
|
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://purplepag.es"
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
}
|
}
|
||||||
|
|
||||||
class Nostr {
|
class Nostr {
|
||||||
@@ -74,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()
|
||||||
|
|
||||||
@@ -98,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)
|
||||||
@@ -140,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() {
|
||||||
@@ -228,7 +262,7 @@ class Nostr {
|
|||||||
|
|
||||||
client?.subscribe(
|
client?.subscribe(
|
||||||
target = ReqTarget.manual(target),
|
target = ReqTarget.manual(target),
|
||||||
id = "all-gift-wraps"
|
id = "gift-wraps"
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
@@ -293,7 +327,7 @@ class Nostr {
|
|||||||
eoseTrackerJob?.cancel()
|
eoseTrackerJob?.cancel()
|
||||||
// Start a new tracker
|
// Start a new tracker
|
||||||
eoseTrackerJob = launch {
|
eoseTrackerJob = launch {
|
||||||
delay(10000) // Wait for 10 seconds
|
delay(10000.milliseconds) // Wait for 10 seconds
|
||||||
onSubscriptionClose()
|
onSubscriptionClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +346,7 @@ class Nostr {
|
|||||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
val subscriptionId = message.subscriptionId
|
val subscriptionId = message.subscriptionId
|
||||||
|
|
||||||
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
|
if (subscriptionId == "gift-wraps") {
|
||||||
onSubscriptionClose()
|
onSubscriptionClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +400,7 @@ class Nostr {
|
|||||||
Tag.identifier(giftId.toHex()),
|
Tag.identifier(giftId.toHex()),
|
||||||
Tag.event(rumor.id()!!),
|
Tag.event(rumor.id()!!),
|
||||||
Tag.reference(roomId.toString()),
|
Tag.reference(roomId.toString()),
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
|
Tag.custom(TagKind.Unknown("k"), listOf("14"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
@@ -395,7 +429,6 @@ class Nostr {
|
|||||||
// Try to unwrap the gift with each signer
|
// Try to unwrap the gift with each signer
|
||||||
for (signer in signers) {
|
for (signer in signers) {
|
||||||
try {
|
try {
|
||||||
// TODO: custom unwrapping logic
|
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
||||||
val rumor = gift.rumor()
|
val rumor = gift.rumor()
|
||||||
// Save the rumor to the database
|
// Save the rumor to the database
|
||||||
@@ -498,20 +531,67 @@ class Nostr {
|
|||||||
setSigner(keys)
|
setSigner(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: String? = null
|
||||||
|
): Metadata {
|
||||||
|
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
val newRecord = record.copy(
|
||||||
|
displayName = name ?: record.displayName,
|
||||||
|
about = bio ?: record.about,
|
||||||
|
picture = picture ?: record.picture
|
||||||
|
)
|
||||||
|
val newMetadata = Metadata.fromRecord(newRecord)
|
||||||
|
val event = EventBuilder.metadata(newMetadata).signAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
return newMetadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
|
return try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.METADATA);
|
||||||
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
try {
|
try {
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)
|
||||||
val results = mutableMapOf<PublicKey, Metadata>()
|
val results = mutableMapOf<PublicKey, Metadata>()
|
||||||
|
|
||||||
events?.toVec()?.forEach { event ->
|
events?.toVec()?.forEach { event ->
|
||||||
val metadata = Metadata.fromJson(event.content())
|
try {
|
||||||
results[event.author()] = metadata
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
results[event.author()] = metadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -594,7 +673,7 @@ class Nostr {
|
|||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all events sent by the user
|
// Get all events sent by the user
|
||||||
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "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
|
||||||
@@ -639,6 +718,8 @@ class Nostr {
|
|||||||
return events
|
return events
|
||||||
?.toVec()
|
?.toVec()
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
|
// Filter out events without public keys (receivers)
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
@@ -692,7 +773,7 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendMessage(
|
suspend fun sendMessage(
|
||||||
to: List<PublicKey>,
|
to: Set<PublicKey>,
|
||||||
content: String,
|
content: String,
|
||||||
subject: String? = null,
|
subject: String? = null,
|
||||||
replies: List<EventId> = emptyList(),
|
replies: List<EventId> = emptyList(),
|
||||||
@@ -718,17 +799,16 @@ class Nostr {
|
|||||||
|
|
||||||
// Add public key tags for each recipient
|
// Add public key tags for each recipient
|
||||||
to.forEach { pubkey ->
|
to.forEach { pubkey ->
|
||||||
if (pubkey != currentUser) {
|
tags.add(Tag.publicKey(pubkey))
|
||||||
tags.add(Tag.publicKey(pubkey))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (receiver in listOf(currentUser) + to) {
|
for (receiver in setOf(currentUser) + to) {
|
||||||
// Construct the rumor event
|
// Construct the rumor event
|
||||||
// NEVER SIGN this event with the current user signer
|
// NEVER SIGN this event with the current user signer
|
||||||
val rumor = EventBuilder
|
val rumor = EventBuilder
|
||||||
.privateMsgRumor(receiver = receiver, message = content)
|
.privateMsgRumor(receiver = receiver, message = content)
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
|
.allowSelfTagging()
|
||||||
.build(currentUser)
|
.build(currentUser)
|
||||||
// Ensure the event ID is set
|
// Ensure the event ID is set
|
||||||
.ensureId()
|
.ensureId()
|
||||||
@@ -805,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,6 +37,7 @@ 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(
|
||||||
@@ -49,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()
|
||||||
|
|
||||||
@@ -75,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()
|
||||||
@@ -117,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) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,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
|
||||||
@@ -247,7 +287,7 @@ class NostrViewModel(
|
|||||||
// Get chat rooms
|
// Get chat rooms
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||||
if (rooms.isNotEmpty()) {
|
if (rooms.isNotEmpty()) {
|
||||||
_chatRooms.value = rooms
|
mergeChatRooms(rooms)
|
||||||
_isPartialProcessedGiftWrap.value = true
|
_isPartialProcessedGiftWrap.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +295,7 @@ class NostrViewModel(
|
|||||||
nostr.getUserMetadata()
|
nostr.getUserMetadata()
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
// Small delay to ensure all relays are connected
|
||||||
delay(3000)
|
delay(3000.milliseconds)
|
||||||
|
|
||||||
// Check if the relay list is empty
|
// Check if the relay list is empty
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
val relays = nostr.getMsgRelays(pubkey)
|
||||||
@@ -266,7 +306,7 @@ class NostrViewModel(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(500)
|
delay(500.milliseconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,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?,
|
||||||
@@ -353,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)
|
||||||
@@ -390,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +539,7 @@ class NostrViewModel(
|
|||||||
|
|
||||||
// Update the chat rooms state
|
// Update the chat rooms state
|
||||||
_chatRooms.update { currentRooms ->
|
_chatRooms.update { currentRooms ->
|
||||||
currentRooms + room
|
(currentRooms + room).sortedDescending().toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
return room.id
|
return room.id
|
||||||
@@ -484,26 +548,33 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room {
|
fun getChatRoom(id: Long): Room? {
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
return chatRooms.value.firstOrNull { it.id == id }
|
||||||
?: throw IllegalArgumentException("Room not found")
|
}
|
||||||
|
|
||||||
|
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||||
|
// Add or update rooms from the database
|
||||||
|
rooms.forEach { room ->
|
||||||
|
merged[room.id] = room
|
||||||
|
}
|
||||||
|
// Return as a sorted set to maintain UI consistency
|
||||||
|
merged.values.sortedDescending().toSet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChatRooms() {
|
fun getChatRooms() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||||
_chatRooms.update { currentRooms ->
|
mergeChatRooms(rooms)
|
||||||
val virtualRooms = currentRooms.filter { local ->
|
|
||||||
rooms.none { db -> db.id == local.id }
|
|
||||||
}
|
|
||||||
rooms + virtualRooms
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
suspend fun refreshChatRooms() {
|
||||||
try {
|
try {
|
||||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||||
|
mergeChatRooms(rooms)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -520,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,9 +613,9 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId)
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
nostr.sendMessage(
|
nostr.sendMessage(
|
||||||
to = room.members.toList(),
|
to = room.members,
|
||||||
content = message,
|
content = message,
|
||||||
subject = room.subject,
|
subject = room.subject,
|
||||||
replies = replies,
|
replies = replies,
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
Reference in New Issue
Block a user