16 Commits

Author SHA1 Message Date
2d830b778f clean up 2026-07-15 17:57:55 +07:00
fcc30157bb Revert "clean up chat screen"
This reverts commit 9c9d9ae882.
2026-07-15 17:02:55 +07:00
43618533a5 improve the profile cache 2026-07-15 08:18:05 +07:00
9c9d9ae882 clean up chat screen 2026-07-15 07:32:39 +07:00
1485a767ed feat: add support for reply message (#44)
Reviewed-on: #44
2026-07-14 10:22:27 +00:00
0e789b5d91 feat: add context menu for chat message (#43)
Reviewed-on: #43
2026-07-13 10:56:23 +00:00
410615bd89 chore: clean up codebase (#42)
Reviewed-on: #42
2026-07-13 01:04:15 +00:00
a5951e03cf chore: improve performance (#41)
Reviewed-on: #41
2026-07-11 12:45:53 +00:00
175c06a1a8 chore: refactor the internal storage layer (#40)
Reviewed-on: #40
2026-07-09 07:53:22 +00:00
c7a646ae73 chore: improve line break handling (#39)
Reviewed-on: #39
2026-07-09 03:48:42 +00:00
7a44e836a9 chore: bump version 2026-07-07 13:29:11 +07:00
4dae4045ae Merge pull request 'chore: improve loading state handling' (#38) from fix-loading-state into master
Reviewed-on: #38
2026-07-07 04:09:28 +00:00
9df4789b62 update 2026-07-07 11:09:06 +07:00
ea7e3fd3ed . 2026-07-07 10:59:09 +07:00
a76c8d117b fix syncing state 2026-07-07 08:27:25 +07:00
9ed29c90ba chore: improve authentication handling (#37)
Reviewed-on: #37
2026-07-06 07:33:06 +00:00
50 changed files with 2391 additions and 1837 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.2.3" versionName = "0.2.4"
} }
packaging { packaging {
resources { resources {

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
</vector>

View File

@@ -26,7 +26,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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
@@ -34,8 +37,9 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import su.reya.coop.repository.ErrorRepository import kotlinx.coroutines.launch
import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -48,20 +52,14 @@ import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.ProfileCache
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No NostrViewModel provided") error("No ProfileCache provided")
}
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided")
}
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided")
} }
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
@@ -79,10 +77,33 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun App( fun App(
nostrViewModel: NostrViewModel, profileCache: ProfileCache,
chatViewModel: ChatViewModel, accountRepository: AccountRepository,
authViewModel: AuthViewModel, chatRepository: ChatRepository,
) { ) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val result = when {
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
chatRepository
)
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
accountRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
return result as T
}
}
}
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
val backStack = rememberNavBackStack(Screen.Home) val backStack = rememberNavBackStack(Screen.Home)
@@ -90,8 +111,8 @@ fun App(
val qrScanResult = remember { QrScanResult() } val qrScanResult = remember { QrScanResult() }
// Get the signer required state // Get the signer required state
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired val signerRequired = accountState.signerRequired
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -118,8 +139,20 @@ fun App(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ErrorRepository.errors.collect { message -> launch {
snackbarHostState.showSnackbar(message) accountViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
profileCache.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
} }
} }
@@ -128,7 +161,7 @@ fun App(
fun handleIntent(intent: Intent) { fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent) val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen // Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) { if ((screen != null) && (backStack.lastOrNull() != screen)) {
navigator.navigate(screen) navigator.navigate(screen)
} }
} }
@@ -161,9 +194,7 @@ fun App(
motionScheme = MotionScheme.expressive(), motionScheme = MotionScheme.expressive(),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel, LocalProfileCache provides profileCache,
LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
@@ -184,43 +215,62 @@ fun App(
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Home> { entry<Screen.Home> {
HomeScreen() HomeScreen(accountViewModel, chatViewModel)
} }
entry<Screen.RequestList> { entry<Screen.RequestList> {
RequestListScreen() RequestListScreen(chatViewModel)
} }
entry<Screen.Onboarding> { entry<Screen.Onboarding> {
OnboardingScreen() OnboardingScreen(accountViewModel)
} }
entry<Screen.Import> { entry<Screen.Import> {
ImportScreen() ImportScreen(accountViewModel)
} }
entry<Screen.NewIdentity> { entry<Screen.NewIdentity> {
NewIdentityScreen() NewIdentityScreen(accountViewModel)
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
ChatScreen(id = key.id, screening = key.screening) val factory = remember(key) {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatScreenViewModel(
key.id,
key.screening,
accountRepository,
chatRepository
) as T
}
}
}
ChatScreen(
viewModel<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
} }
entry<Screen.NewChat> { entry<Screen.NewChat> {
NewChatScreen() NewChatScreen(accountViewModel, chatViewModel)
} }
entry<Screen.Profile> { key -> entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey) ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
} }
entry<Screen.UpdateProfile> { entry<Screen.UpdateProfile> {
UpdateProfileScreen() UpdateProfileScreen(accountViewModel)
} }
entry<Screen.Scan> { entry<Screen.Scan> {
ScanScreen() ScanScreen()
} }
entry<Screen.MyQr> { entry<Screen.MyQr> {
MyQrScreen() MyQrScreen(accountViewModel)
} }
entry<Screen.ContactList> { entry<Screen.ContactList> {
ContactListScreen() ContactListScreen(accountViewModel, chatViewModel)
} }
entry<Screen.Relay> { entry<Screen.Relay> {
RelayScreen() RelayScreen(accountViewModel)
} }
} }
) )

View File

@@ -1,8 +1,13 @@
package su.reya.coop.coop.storage package su.reya.coop
import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.KeyStore import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
data class SecretEntry( private val Context.dataStore by preferencesDataStore("secret_store")
val encrypted: String,
val iv: String data class SecretEntry(val encrypted: String, val iv: String)
)
class SecretCrypto { class SecretCrypto {
private val keyAlias = "coop" private val keyAlias = "coop"
@@ -21,11 +25,9 @@ class SecretCrypto {
private val transformation = "AES/GCM/NoPadding" private val transformation = "AES/GCM/NoPadding"
fun encrypt(content: String): SecretEntry { fun encrypt(content: String): SecretEntry {
// Initialize cipher
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// Encrypt content
val encrypted = cipher.doFinal(content.toByteArray()) val encrypted = cipher.doFinal(content.toByteArray())
val iv = cipher.iv val iv = cipher.iv
@@ -39,12 +41,10 @@ class SecretCrypto {
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP) val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
val iv = Base64.decode(entry.iv, Base64.NO_WRAP) val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
// Initialize cipher
val cipher = Cipher.getInstance(transformation) val cipher = Cipher.getInstance(transformation)
val spec = GCMParameterSpec(128, iv) val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec) cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
// Decrypt content
val plaintext = cipher.doFinal(encrypted) val plaintext = cipher.doFinal(encrypted)
return String(plaintext, StandardCharsets.UTF_8) return String(plaintext, StandardCharsets.UTF_8)
@@ -54,13 +54,9 @@ class SecretCrypto {
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) } val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
val existingKey = keyStore.getKey(keyAlias, null) val existingKey = keyStore.getKey(keyAlias, null)
// Return existing key if available
if (existingKey is SecretKey) return existingKey if (existingKey is SecretKey) return existingKey
// Construct a new key generator
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType) val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
// Initialize key generation parameters
val spec = KeyGenParameterSpec.Builder( val spec = KeyGenParameterSpec.Builder(
keyAlias, keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -70,9 +66,49 @@ class SecretCrypto {
.setKeySize(256) .setKeySize(256)
.build() .build()
// Generate a new key
keyGenerator.init(spec) keyGenerator.init(spec)
return keyGenerator.generateKey() return keyGenerator.generateKey()
} }
} }
class AppStore(private val context: Context) : AppStorage {
private val crypto = SecretCrypto()
override suspend fun get(key: String): String? {
return context.dataStore.data.first()[stringPreferencesKey(key)]
}
override suspend fun set(key: String, value: String) {
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun getSecret(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun setSecret(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey(key))
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs.contains(stringPreferencesKey(key)) ||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
}
}

View File

@@ -4,12 +4,15 @@ import android.content.Intent
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class ExternalSignerLauncher { class ExternalSignerLauncher(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
private var launcher: ActivityResultLauncher<Intent>? = null private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex() private val mutex = Mutex()
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
} }
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock { suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
withContext(Dispatchers.Main) { withContext(mainDispatcher) {
val deferred = CompletableDeferred<ActivityResult>() val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred pendingResult = deferred
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered") launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")

View File

@@ -8,15 +8,13 @@ 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.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel import kotlinx.coroutines.MainScope
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.viewmodel.AuthViewModel import su.reya.coop.repository.AccountRepository
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.repository.ChatRepository
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -24,32 +22,20 @@ class MainActivity : ComponentActivity() {
val externalSignerLauncher = ExternalSignerLauncher() val externalSignerLauncher = ExternalSignerLauncher()
} }
private val factory by lazy { private val profileCache by lazy { ProfileCache(NostrManager.instance) }
object : ViewModelProvider.Factory { private val scope = MainScope()
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val secretStore = SecretStore(this@MainActivity)
private val nostrViewModel =
NostrViewModel(NostrManager.instance)
private val chatViewModel =
ChatViewModel(NostrManager.instance)
private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T { private val accountRepository by lazy {
return when { val storage = AppStore(this@MainActivity)
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel val mediaRepository = MediaRepository()
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}
}
} }
private val nostrViewModel: NostrViewModel by viewModels { factory } private val chatRepository by lazy {
private val chatViewModel: ChatViewModel by viewModels { factory } val mediaRepository = MediaRepository()
private val authViewModel: AuthViewModel by viewModels { factory } ChatRepository(NostrManager.instance, mediaRepository, scope)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
@@ -88,14 +74,14 @@ class MainActivity : ComponentActivity() {
// 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 {
authViewModel.state.value.signerRequired == null accountRepository.state.value.signerRequired == null
} }
setContent { setContent {
App( App(
nostrViewModel = nostrViewModel, profileCache = profileCache,
chatViewModel = chatViewModel, accountRepository = accountRepository,
authViewModel = authViewModel, chatRepository = chatRepository,
) )
} }
} }

View File

@@ -14,6 +14,7 @@ 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.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -27,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() { class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val nostr by lazy { NostrManager.instance } private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null private var notificationJob: Job? = null

View File

@@ -33,11 +33,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -55,27 +53,27 @@ import coop.composeapp.generated.resources.ic_check
import coop.composeapp.generated.resources.ic_close import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_plus
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.short import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ContactListScreen() { fun ContactListScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) } var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) } var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
@@ -191,7 +189,10 @@ fun ContactListScreen() {
) )
if (openAddContactDialog) { if (openAddContactDialog) {
AddContactDialog(onDismissRequest = { openAddContactDialog = false }) AddContactDialog(
accountViewModel = accountViewModel,
onDismissRequest = { openAddContactDialog = false }
)
} }
if (contactToDelete != null) { if (contactToDelete != null) {
@@ -202,7 +203,7 @@ fun ContactListScreen() {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
contactToDelete?.let { nostrViewModel.removeContact(it) } contactToDelete?.let { accountViewModel.removeContact(it) }
contactToDelete = null contactToDelete = null
} }
) { ) {
@@ -220,15 +221,15 @@ fun ContactListScreen() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun AddContactDialog(onDismissRequest: () -> Unit) { fun AddContactDialog(
accountViewModel: AccountViewModel,
onDismissRequest: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") } var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) } var isError by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
@@ -266,10 +267,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { accountViewModel.addContact(contact)
val success = nostrViewModel.addContact(contact) onDismissRequest()
if (success) onDismissRequest()
}
}) { }) {
Icon( Icon(
painter = painterResource(Res.drawable.ic_check), painter = painterResource(Res.drawable.ic_check),
@@ -326,9 +325,9 @@ fun ContactListItem(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) { ) {
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null) val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
SegmentedListItem( SegmentedListItem(
onClick = onClick, onClick = onClick,

View File

@@ -89,46 +89,47 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomKind import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.rememberUiState
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen() { fun HomeScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true) val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed val isBannerDismissed = accountState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -155,7 +156,7 @@ fun HomeScreen() {
onPauseOrDispose { } onPauseOrDispose { }
} }
LaunchedEffect(Unit) { LaunchedEffect(accountState.signerRequired) {
chatViewModel.refreshChatRooms() chatViewModel.refreshChatRooms()
} }
@@ -280,7 +281,7 @@ fun HomeScreen() {
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
TextButton( TextButton(
onClick = { authViewModel.dismissNotificationBanner() }, onClick = { accountViewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text(text = "Maybe later") Text(text = "Maybe later")
@@ -319,11 +320,9 @@ fun HomeScreen() {
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = pullToRefreshState, state = pullToRefreshState,
onRefresh = { onRefresh = {
scope.launch { isRefreshing = true
isRefreshing = true chatViewModel.refreshChatRooms()
chatViewModel.refreshChatRooms() isRefreshing = false
isRefreshing = false
}
}, },
indicator = { indicator = {
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(
@@ -461,7 +460,11 @@ fun HomeScreen() {
} }
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun) BottomMenuList(
onDismiss = dismissAndRun,
accountViewModel = accountViewModel,
chatViewModel = chatViewModel
)
} }
} }
} }
@@ -469,7 +472,7 @@ fun HomeScreen() {
// Show the relay setup dialog if the msg relay list is empty // Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) { if (isRelayListEmpty) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { nostrViewModel.dismissRelayWarning() }, onDismissRequest = { accountViewModel.dismissRelayWarning() },
sheetState = sheetState, sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
) { ) {
@@ -573,15 +576,9 @@ fun HomeScreen() {
TextButton( TextButton(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { isBusy = true
isBusy = true accountViewModel.refetchMsgRelays()
try { isBusy = false
nostrViewModel.refetchMsgRelays()
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
}
isBusy = false
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -595,10 +592,8 @@ fun HomeScreen() {
Button( Button(
enabled = !isBusy, enabled = !isBusy,
onClick = { onClick = {
scope.launch { accountViewModel.useDefaultMsgRelayList()
nostrViewModel.useDefaultMsgRelayList() scope.launch { sheetState.hide() }
sheetState.hide()
}
}, },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -619,14 +614,16 @@ fun HomeScreen() {
@Composable @Composable
fun NewRequests(requests: List<Room>) { fun NewRequests(requests: List<Room>) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val total = requests.size val total = requests.size
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) .collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
total == 1 -> { total == 1 -> {
@@ -697,8 +694,8 @@ fun NewRequests(requests: List<Room>) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val roomState by room.rememberUiState(nostrViewModel) val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
@@ -741,12 +738,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun BottomMenuList( fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit onDismiss: (suspend () -> Unit) -> Unit,
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel,
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
@@ -778,8 +774,8 @@ fun BottomMenuList(
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
onDismiss { onDismiss {
authViewModel.logout(onLogout = { accountViewModel.logout(onLogout = {
nostrViewModel.resetInternalState() accountViewModel.resetInternalState()
chatViewModel.resetInternalState() chatViewModel.resetInternalState()
}) })
} }

View File

@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -30,68 +29,49 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ImportScreen() { fun ImportScreen(viewModel: AccountViewModel) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val nostrViewModel = LocalNostrViewModel.current val accountState by viewModel.state.collectAsStateWithLifecycle()
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) } var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
val profile by remember(pubkey) {
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
if (result.startsWith("nsec1")) { if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
Keys.parse(result) Keys.parse(result)
} else if (result.startsWith("bunker://")) { } else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result) NostrConnectUri.parse(result)
@@ -101,13 +81,33 @@ fun ImportScreen() {
}.onSuccess { }.onSuccess {
secret = result secret = result
}.onFailure { e -> }.onFailure { e ->
snackbarHostState.showSnackbar("Invalid secret: ${e.message}") e.message?.let { snackbarHostState.showSnackbar(it) }
} }
// Clear the nav state // Clear the nav state
qrScanResult.clear() qrScanResult.clear()
} }
} }
LaunchedEffect(secret) {
if (secret.startsWith("ncryptsec1")) {
requirePassword = true
}
}
// Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show import errors via snackbar
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -147,35 +147,6 @@ fun ImportScreen() {
.padding(top = innerPadding.calculateTopPadding()) .padding(top = innerPadding.calculateTopPadding())
.imePadding(), .imePadding(),
) { ) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = profile?.picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = profile?.name ?: "",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
}
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -186,7 +157,7 @@ fun ImportScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp) .padding(24.dp),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -203,9 +174,9 @@ fun ImportScreen() {
BasicTextField( BasicTextField(
value = secret, value = secret,
onValueChange = { secret = it }, onValueChange = { secret = it },
enabled = !isBusy, enabled = !accountState.isImporting,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxLines = 4, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
@@ -237,32 +208,57 @@ fun ImportScreen() {
} }
} }
) )
Spacer(modifier = Modifier.size(8.dp))
if (requirePassword) {
Text(
text = "Decrypt Password:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = password,
onValueChange = { password = it },
enabled = !accountState.isImporting && requirePassword,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
innerTextField()
}
}
)
}
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
scope.launch { viewModel.importIdentity(secret, password)
if (pubkey == null) {
authViewModel.verifyIdentity(secret).let { pubkey = it }
} else {
// Import the identity
authViewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
}
}
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isBusy, enabled = secret.isNotBlank() && !accountState.isImporting,
) { ) {
if (isBusy) { if (accountState.isImporting) {
LoadingIndicator() LoadingIndicator()
} else { } else {
Text( Text(
text = if (pubkey == null) "Verify" else "Click again to Continue", text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -22,15 +22,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@Composable @Composable
fun MyQrScreen() { fun MyQrScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },

View File

@@ -55,26 +55,27 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.short import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun NewChatScreen() { fun NewChatScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val nostrViewModel = LocalNostrViewModel.current val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }
@@ -96,14 +97,16 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
} }
} else if (query.contains("@")) { } else if (query.contains("@")) {
val pubkey = nostrViewModel.searchByAddress(query) accountViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) { if (pubkey != null) {
selectedReceivers.add(pubkey) selectedReceivers.add(pubkey)
}
} }
} else { } else {
val results = nostrViewModel.searchByNostr(query) accountViewModel.searchByNostr(query) { results ->
searchResults.clear() searchResults.clear()
searchResults.addAll(results) searchResults.addAll(results)
}
} }
query = "" query = ""
@@ -290,8 +293,8 @@ fun ReceiverChip(
pubkey: PublicKey, pubkey: PublicKey,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
@@ -372,8 +375,8 @@ fun ContactListItem(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
SegmentedListItem( SegmentedListItem(
@@ -394,7 +397,7 @@ fun ContactListItem(
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = profile?.name ?: "", text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -2,33 +2,25 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@Composable @Composable
fun NewIdentityScreen() { fun NewIdentityScreen(viewModel: AccountViewModel) {
val authViewModel = LocalAuthViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val accountState by viewModel.state.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBusy = authState.isBusy
ProfileEditor( ProfileEditor(
title = "Create a new identity", title = "Create a new identity",
buttonLabel = "Continue", buttonLabel = "Continue",
isBusy = isBusy, isBusy = accountState.isImporting,
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { viewModel.createIdentity(name, bio, bytes, type)
authViewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home)
navigator.navigate(Screen.Home)
}
} }
) )
} }

View File

@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -41,25 +43,40 @@ 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.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen() { fun OnboardingScreen(viewModel: AccountViewModel) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current val accountState by viewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
// Show connection errors
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -157,15 +174,12 @@ fun OnboardingScreen() {
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
scope.launch { if (viewModel.isExternalSignerAvailable()) {
if (authViewModel.isExternalSignerAvailable()) { // Connect to the external signer
try { // TODO: show all available signers?
authViewModel.connectExternalSigner() viewModel.connectExternalSigner()
navigator.navigate(Screen.Home) } else {
} catch (e: Exception) { scope.launch {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.", message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install", actionLabel = "Install",

View File

@@ -44,28 +44,30 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short import su.reya.coop.short
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen(pubkey: String) { fun ProfileScreen(
pubkey: String,
chatViewModel: ChatViewModel
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
val metadata = profile?.metadata?.asRecord() val metadata = profile?.metadata?.asRecord()

View File

@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
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_check import coop.composeapp.generated.resources.ic_check
@@ -66,15 +67,14 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RelayScreen() { fun RelayScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() } val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
@@ -96,8 +96,25 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) } var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
relayList.putAll(nostrViewModel.currentUserRelayList()) viewModel.loadCurrentUserRelayList()
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) viewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
relayList.putAll(loadedRelayList)
}
}
LaunchedEffect(loadedMsgRelayList) {
if (loadedMsgRelayList.isNotEmpty()) {
msgRelayList.clear()
msgRelayList.addAll(loadedMsgRelayList)
}
} }
Scaffold( Scaffold(
@@ -296,6 +313,7 @@ fun RelayScreen() {
if (openAddRelayDialog) { if (openAddRelayDialog) {
AddRelayDialog( AddRelayDialog(
viewModel = viewModel,
onDismissRequest = { openAddRelayDialog = false }, onDismissRequest = { openAddRelayDialog = false },
onMsgRelayAdded = { newRelay -> onMsgRelayAdded = { newRelay ->
msgRelayList.add(RelayUrl.parse(newRelay)) msgRelayList.add(RelayUrl.parse(newRelay))
@@ -314,20 +332,16 @@ fun RelayScreen() {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
scope.launch { if (msgRelayList.size == 1) {
if (msgRelayList.size == 1) { scope.launch {
snackbarHostState.showSnackbar("You must have at least one relay") snackbarHostState.showSnackbar("You must have at least one relay")
relayToDelete = null
return@launch
}
try {
nostrViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
} }
relayToDelete = null
return@TextButton
} }
viewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
} }
) { ) {
Text("Confirm") Text("Confirm")
@@ -345,14 +359,12 @@ fun RelayScreen() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun AddRelayDialog( fun AddRelayDialog(
viewModel: AccountViewModel,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onMsgRelayAdded: (newRelay: String) -> Unit, onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) { ) {
val nostrViewModel = LocalNostrViewModel.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var relayAddress by remember { mutableStateOf("") } var relayAddress by remember { mutableStateOf("") }
@@ -397,26 +409,24 @@ fun AddRelayDialog(
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
scope.launch { if (!isError) {
if (!isError) { when (selected) {
when (selected) { "Messaging" -> {
"Messaging" -> { viewModel.addMsgRelay(relayAddress)
nostrViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress)
onMsgRelayAdded(relayAddress) }
}
"Inbox" -> {
"Inbox" -> { viewModel.addInboxRelay(relayAddress)
nostrViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE)
onRelayAdded(relayAddress, RelayMetadata.WRITE) }
}
"Outbox" -> {
"Outbox" -> { viewModel.addOutboxRelay(relayAddress)
nostrViewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
} }
onDismissRequest()
} }
onDismissRequest()
} }
}) { }) {
Icon( Icon(

View File

@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 kotlinx.coroutines.launch
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun RequestListScreen() { fun RequestListScreen(viewModel: ChatViewModel) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms // Get all request rooms
val requests = remember(chatRooms) { val requests = remember(chatRooms) {
@@ -101,11 +97,9 @@ fun RequestListScreen() {
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = pullToRefreshState, state = pullToRefreshState,
onRefresh = { onRefresh = {
scope.launch { isRefreshing = true
isRefreshing = true viewModel.refreshChatRooms()
chatViewModel.refreshChatRooms() isRefreshing = false
isRefreshing = false
}
}, },
indicator = { indicator = {
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(

View File

@@ -2,22 +2,17 @@ package su.reya.coop.screens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@Composable @Composable
fun UpdateProfileScreen() { fun UpdateProfileScreen(viewModel: AccountViewModel) {
val nostrViewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord() val profile = currentUser?.metadata?.asRecord()
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
ProfileEditor( ProfileEditor(
title = "Update profile", title = "Update profile",
@@ -25,13 +20,11 @@ fun UpdateProfileScreen() {
initialName = profile?.displayName ?: profile?.name ?: "", initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "", initialBio = profile?.about ?: "",
initialPicture = profile?.picture, initialPicture = profile?.picture,
isBusy = isBusy, isBusy = isUpdatingProfile,
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { viewModel.updateProfile(name, bio, bytes, type)
nostrViewModel.updateProfile(name, bio, bytes, type) navigator.goBack()
navigator.goBack()
}
} }
) )
} }

View File

@@ -18,52 +18,47 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.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.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_check_circle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.humanReadable import su.reya.coop.humanReadable
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ScreenerCard(room: Room) { fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
val pubkey = room.members.firstOrNull() ?: return val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) } var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) } var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) } var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { LaunchedEffect(pubkey) {
scope.launch { // Check contact
// Check contact viewModel.verifyContact(pubkey) { isContact = it }
nostrViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts
// Get mutual contacts viewModel.mutualContacts(pubkey) { mutualContacts = it }
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity
// Get the last activity viewModel.verifyActivity(pubkey) { lastActivity = it }
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
} }
Column( Column(

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
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.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_add_circle import coop.composeapp.generated.resources.ic_add_circle
@@ -52,6 +55,11 @@ fun ChatInput(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
placeholder = { Text("Message") }, placeholder = { Text("Message") },
maxLines = 5,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Default
),
leadingIcon = { leadingIcon = {
IconButton(onClick = onUpload) { IconButton(onClick = onUpload) {
Icon( Icon(

View File

@@ -5,7 +5,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -26,8 +26,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -37,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.URL_REGEX import su.reya.coop.URL_REGEX
@@ -45,27 +49,25 @@ import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls import su.reya.coop.removeImageUrls
@Immutable @Immutable
data class MessageUiModel( data class MessageModel(
val id: String, val id: EventId,
val author: PublicKey,
val annotatedContent: AnnotatedString, val annotatedContent: AnnotatedString,
val images: List<String>, val images: List<String>,
val timestamp: String, val timestamp: String,
val isMine: Boolean val isMine: Boolean,
val replyEventIds: List<EventId>
) )
@Composable @Composable
fun rememberMessageUiModel( fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
event: UnsignedEvent, return remember(event, currentUser) {
currentUserPublicKey: PublicKey?, val id = event.ensureId().id()!!
contentColor: Color val isMine = currentUser == event.author()
): MessageUiModel {
return remember(event, currentUserPublicKey, contentColor) {
val content = event.content() val content = event.content()
val images = URL_REGEX.findAll(content) val replyEventIds = event.tags().eventIds()
.map { it.value }
.filter { it.isImageUrl() }
.toList()
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
val cleanedContent = content.removeImageUrls() val cleanedContent = content.removeImageUrls()
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
@@ -78,7 +80,6 @@ fun rememberMessageUiModel(
url = url, url = url,
styles = TextLinkStyles( styles = TextLinkStyles(
style = SpanStyle( style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -92,19 +93,26 @@ fun rememberMessageUiModel(
append(cleanedContent.substring(lastIndex)) append(cleanedContent.substring(lastIndex))
} }
MessageUiModel( MessageModel(
id = event.id()?.toHex() ?: event.hashCode().toString(), id = id,
author = event.author(),
annotatedContent = annotatedString, annotatedContent = annotatedString,
images = images, images = images,
timestamp = event.createdAt().formatAsTime(), timestamp = event.createdAt().formatAsTime(),
isMine = event.author() == currentUserPublicKey isMine = isMine,
replyEventIds = replyEventIds
) )
} }
} }
@Composable @Composable
fun ChatMessage(model: MessageUiModel) { fun ChatMessage(
model: MessageModel,
modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {}
) {
var isMessageClicked by remember { mutableStateOf(false) } var isMessageClicked by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val bubbleShape = if (model.isMine) { val bubbleShape = if (model.isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
@@ -119,27 +127,32 @@ fun ChatMessage(model: MessageUiModel) {
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
Box( Box(
modifier = Modifier modifier = modifier
.onGloballyPositioned { layoutCoordinates = it }
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Column( Column(
modifier = Modifier.clickable( modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null,
) { onClick = { isMessageClicked = !isMessageClicked },
isMessageClicked = !isMessageClicked onLongClick = {
}, layoutCoordinates?.let { coords ->
onLongClick(coords.boundsInWindow())
}
}
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (model.annotatedContent.isNotBlank()) { if (model.annotatedContent.isNotBlank()) {
Surface( Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
shape = bubbleShape, shape = bubbleShape,
modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Text( Text(
text = model.annotatedContent, text = model.annotatedContent,

View File

@@ -1,11 +1,17 @@
package su.reya.coop.screens.chat package su.reya.coop.screens.chat
import android.app.Activity import android.app.Activity
import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.speech.RecognizerIntent import android.speech.RecognizerIntent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,24 +22,33 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -45,52 +60,67 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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_copy
import coop.composeapp.generated.resources.ic_reply
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroup
import su.reya.coop.rememberUiState
import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatScreen(id: Long, screening: Boolean = false) { fun ChatScreen(
viewModel: ChatScreenViewModel,
accountViewModel: AccountViewModel,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current val profileCache = LocalProfileCache.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Get current user val id = viewModel.id
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
// Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen // Show empty screen
@@ -108,32 +138,38 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
return return
} }
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) val loading = viewModel.loading
var text by remember { mutableStateOf("") } val newOtherMessages = viewModel.newOtherMessages
var loading by remember { mutableStateOf(true) } val requireScreening = viewModel.requireScreening
var newOtherMessages by remember { mutableIntStateOf(0) } val messages = viewModel.messages
var requireScreening by remember { mutableStateOf(screening) }
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
val blurAmount by animateDpAsState(
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
label = "blurAnimation"
)
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
try { // Read file on IO dispatcher
// Read file val file = withContext(coroutineDispatcher) {
val file = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
} }
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
viewModel.sendFileMessage(file, type)
} }
} }
@@ -151,235 +187,429 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
} }
} }
LaunchedEffect(id) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
// Get msg relays for each member
chatViewModel.chatRoomConnect(id)
// Handle new messages
chatViewModel.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
// If the event is not in the current room, it's a new message from another user
newOtherMessages++
}
}
}
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
} }
Scaffold( Box(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), modifier = Modifier.fillMaxSize()
containerColor = MaterialTheme.colorScheme.surfaceContainer, ) {
snackbarHost = { SnackbarHost(snackbarHostState) }, Scaffold(
topBar = { modifier = Modifier.blur(blurAmount),
TopAppBar( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
title = { containerColor = MaterialTheme.colorScheme.surfaceContainer,
Row( snackbarHost = { SnackbarHost(snackbarHostState) },
verticalAlignment = Alignment.CenterVertically, topBar = {
modifier = Modifier.clickable { TopAppBar(
room?.members?.firstOrNull()?.let { pubkey -> title = {
navigator.navigate(Screen.Profile(pubkey.toBech32())) Row(
} verticalAlignment = Alignment.CenterVertically,
} modifier = Modifier.clickable {
) { room?.members?.firstOrNull()?.let { pubkey ->
if (loading) { navigator.navigate(Screen.Profile(pubkey.toBech32()))
LoadingIndicator(modifier = Modifier.size(32.dp))
} else {
Avatar(
picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
},
navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
} }
} }
} ) {
) { if (loading) {
IconButton(onClick = { navigator.goBack() }) { LoadingIndicator(modifier = Modifier.size(32.dp))
Icon( } else {
painter = painterResource(Res.drawable.ic_arrow_back), Avatar(
contentDescription = "Back" picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
} },
}, navigationIcon = {
colors = TopAppBarDefaults.topAppBarColors( BadgedBox(
containerColor = MaterialTheme.colorScheme.surfaceContainer, badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
) )
) },
}, content = { innerPadding ->
content = { innerPadding -> Surface(
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
if (requireScreening) { Column(
room?.let { ScreenerCard(it) } modifier = Modifier
} .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer when (messages.isNotEmpty()) {
val otherColor = MaterialTheme.colorScheme.onSurface true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
) {
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val model =
rememberMessageModel(event, currentUser?.publicKey)
when (messages.isNotEmpty()) { val replyPreview =
true -> { remember(model.replyEventIds, messages.size) {
LazyColumn( model.replyEventIds.firstOrNull()
modifier = Modifier ?.let { replyId ->
.weight(1f) messages.find { it.id() == replyId }
.fillMaxWidth(), }
contentPadding = PaddingValues(16.dp), }
reverseLayout = true,
state = listState, Column(
) { modifier = Modifier.fillMaxWidth(),
groupedMessages.value.forEach { (dateHeader, messagesInGroup) -> verticalArrangement = Arrangement.spacedBy(2.dp)
items( ) {
items = messagesInGroup, replyPreview?.let { ReplyPreview(it, model.isMine) }
key = { it.id()?.toHex() ?: it.hashCode().toString() } ChatMessage(
) { event -> model = model,
val isMine = currentUser?.publicKey == event.author() modifier = Modifier.graphicsLayer {
val uiModel = rememberMessageUiModel( alpha =
event = event, if (selectedMessage?.first?.id == model.id) 0f else 1f
currentUserPublicKey = currentUser?.publicKey, },
contentColor = if (isMine) mineColor else otherColor onLongClick = { rect ->
selectedMessage = model to rect
}
)
}
}
item {
DateSeparator(dateHeader)
}
}
}
}
false -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
) )
ChatMessage(model = uiModel)
}
item {
DateSeparator(dateHeader)
} }
} }
} }
} }
false -> { when (requireScreening) {
Box( true -> {
modifier = Modifier Row(
.weight(1f) modifier = Modifier
.fillMaxWidth(), .fillMaxWidth()
contentAlignment = Alignment.Center .padding(horizontal = 16.dp, vertical = 8.dp),
) { horizontalArrangement = Arrangement.spacedBy(8.dp),
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Button(
text = "No messages yet", onClick = { navigator.goBack() },
style = MaterialTheme.typography.titleLargeEmphasized.copy( modifier = Modifier
fontWeight = FontWeight.SemiBold .weight(1f)
), .size(ButtonDefaults.MediumContainerHeight)
color = MaterialTheme.colorScheme.onSurface ) {
) Text(
Text( text = "Reject",
text = "Your conversations will appear here.", style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodyMedium, )
color = MaterialTheme.colorScheme.outline }
) FilledTonalButton(
onClick = { viewModel.requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
} }
} }
}
}
when (requireScreening) { else -> {
true -> { replyingTo?.let {
Row( ReplyBox(it) { replyingTo = null }
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Reject",
style = MaterialTheme.typography.titleMedium,
)
} }
FilledTonalButton( ChatInput(
onClick = { requireScreening = false }, value = text,
modifier = Modifier onValueChange = { text = it },
.weight(1f) onSend = {
.size(ButtonDefaults.MediumContainerHeight) viewModel.sendMessage(text)
) { text = ""
Text( },
text = "Accept", onUpload = {
style = MaterialTheme.typography.titleMedium, fileLauncher.launch("image/*")
) },
} onMicClick = {
} val intent =
} Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
else -> { RecognizerIntent.EXTRA_LANGUAGE_MODEL,
ChatInput( RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
value = text, )
onValueChange = { text = it }, putExtra(
onSend = { RecognizerIntent.EXTRA_PROMPT,
chatViewModel.sendMessage(id, text) "Speak now..."
text = "" )
}, }
onUpload = { try {
fileLauncher.launch("image/*") sttLauncher.launch(intent)
}, } catch (e: Exception) {
onMicClick = { scope.launch {
val intent = snackbarHostState.showSnackbar("Speech recognition not available")
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { }
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
}
try {
sttLauncher.launch(intent)
} catch (e: Exception) {
scope.launch {
snackbarHostState.showSnackbar("Speech recognition not available")
} }
} }
} )
) }
} }
} }
} }
} }
)
AnimatedVisibility(
visible = selectedMessage != null,
enter = fadeIn(),
exit = fadeOut()
) {
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 12.dp.toPx() }
val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
ChatMessage(
model = model,
modifier = Modifier
.offset { IntOffset(0, bounds.top.toInt()) }
.padding(horizontal = 16.dp)
)
val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box(
modifier = Modifier
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu { action ->
when (action) {
"Copy" -> {
scope.launch {
val content = model.annotatedContent
val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
"Reply" -> {
replyingTo = model
}
else -> {}
}
selectedMessage = null
}
}
}
} }
) }
}
@Composable
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
val profile by profileFlow.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onDismiss() },
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Replying to ${profile?.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = model.annotatedContent.toString().ifBlank {
if (model.images.isNotEmpty()) "[Image]" else ""
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
@Composable
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle()
val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = event.content(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 1,
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(onAction: (String) -> Unit) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
)
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon) ->
DropdownMenuItem(
shapes = MenuDefaults.itemShape(index, itemCount),
colors = MenuDefaults.selectableItemVibrantColors(),
text = { Text(label) },
leadingIcon = {
Icon(
painter = painterResource(icon),
contentDescription = label,
)
},
checked = false,
onCheckedChange = { _ -> onAction(label) },
)
}
}
} }

View File

@@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -70,7 +71,8 @@ fun ProfileEditor(
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false, isBusy: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
@@ -267,14 +269,18 @@ fun ProfileEditor(
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
onClick = { onClick = {
scope.launch { scope.launch {
val bytes = withContext(Dispatchers.IO) { try {
(picture as? Uri)?.let { val bytes = withContext(ioDispatcher) {
context.contentResolver.openInputStream(it)?.readBytes() (picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}
} }
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
} }
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
} }
}, },
enabled = name.isNotBlank() && !isBusy enabled = name.isNotBlank() && !isBusy

View File

@@ -1,43 +0,0 @@
package su.reya.coop.coop.storage
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import su.reya.coop.storage.SecretStorage
private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto()
override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${key}_encrypted")] != null
}
}

View File

@@ -33,6 +33,8 @@ kotlin {
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(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
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.3.2") implementation("su.reya:nostr-sdk-kmp:0.3.2")

View File

@@ -0,0 +1,14 @@
package su.reya.coop
interface AppStorage {
// Plain text storage
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
// Encrypted storage
suspend fun getSecret(key: String): String?
suspend fun setSecret(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -2,25 +2,23 @@ package su.reya.coop
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE) val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
fun String.extractUrls(): List<String> {
return URL_REGEX.findAll(this).map { it.value }.toList()
}
fun String.removeImageUrls(): String { fun String.removeImageUrls(): String {
return URL_REGEX.replace(this) { result -> return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim()
} }
fun String.isImageUrl(): Boolean { fun String.isImageUrl(): Boolean {
val extension = this.substringAfterLast('.', "").lowercase() val extension = this.substringAfterLast('.', "").lowercase()
return extension in imageExtensions return extension in imageExtensions
} }
fun String.sanitizeName(): String {
return this.replace("\n", " ").replace("\r", " ").trim()
}
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}

View File

@@ -10,8 +10,8 @@ data class Profile(
private val record by lazy { metadata.asRecord() } private val record by lazy { metadata.asRecord() }
val name: String val name: String
get() = record.displayName ?: record.name ?: publicKey.short() get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
val picture: String? val picture: String?
get() = record.picture get() = record.picture

View File

@@ -1,9 +1,5 @@
package su.reya.coop package su.reya.coop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -15,7 +11,7 @@ import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.ProfileCache
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@@ -41,8 +37,7 @@ data class Room(
} }
companion object { companion object {
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room { fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt() val createdAt = rumor.createdAt()
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content() val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
@@ -67,22 +62,6 @@ data class Room(
} }
} }
fun setKind(kind: RoomKind): Room {
return this.copy(kind = kind)
}
fun setCreatedAt(createdAt: Timestamp): Room {
return this.copy(createdAt = createdAt)
}
fun setSubject(subject: String?): Room {
return this.copy(subject = subject)
}
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean = members.size > 1 fun isGroup(): Boolean = members.size > 1
} }
@@ -93,17 +72,19 @@ data class RoomUiState(
) )
fun Room.uiStateFlow( fun Room.uiStateFlow(
nostrViewModel: NostrViewModel, profileCache: ProfileCache,
currentUser: PublicKey? = null currentUser: PublicKey? = null
): Flow<RoomUiState> { ): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1) val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) { if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject, isGroup = isGroup())) return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
} }
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() } val names = profiles.mapIndexed { i, profile ->
profile?.name?.sanitizeName() ?: displayMembers[i].short()
}
val name = when { val name = when {
isGroup() -> { isGroup() -> {
@@ -126,31 +107,13 @@ fun Room.uiStateFlow(
} }
} }
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
}
fun UnsignedEvent.roomId(): Long { fun UnsignedEvent.roomId(): Long {
// Collect the author's public key and all public keys from tags // Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf() val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author()) pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys()) pubkeys.addAll(this.tags().publicKeys())
// Sort and hash the list of public keys return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
} }
fun Timestamp.formatAsTime(): String { fun Timestamp.formatAsTime(): String {
@@ -182,7 +145,7 @@ fun Timestamp.ago(): String {
} }
} }
fun Timestamp.formatAsGroupHeader(): String { fun Timestamp.formatAsGroup(): String {
val timeZone = TimeZone.currentSystemDefault() val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDate = inputInstant.toLocalDateTime(timeZone).date val inputDate = inputInstant.toLocalDateTime(timeZone).date

View File

@@ -1,5 +1,6 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -72,6 +73,8 @@ class MessageManager(private val nostr: Nostr) {
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "gift-wraps" id = "gift-wraps"
) )
} catch (e: CancellationException) {
throw e
} 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)
} }
@@ -119,7 +122,9 @@ class MessageManager(private val nostr: Nostr) {
setCachedRumor(event.id(), unsignedEvent) setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent return unsignedEvent
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null return null
} }
@@ -131,7 +136,9 @@ class MessageManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() } return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
} }
} }
@@ -155,7 +162,9 @@ class MessageManager(private val nostr: Nostr) {
.finalizeAsync(Keys.generate()) .finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Throwable) { } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to set cached rumor: ${e.message}") println("Failed to set cached rumor: ${e.message}")
} }
} }
@@ -170,36 +179,35 @@ class MessageManager(private val nostr: Nostr) {
// Get all DM events // Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms // Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf() val roomsMap: MutableMap<Long, Room> = mutableMapOf()
events events
?.toVec() .map { UnsignedEvent.fromJson(it.content()) }
?.map { UnsignedEvent.fromJson(it.content()) } .filter { it.tags().publicKeys().isNotEmpty() }
?.filter { it.tags().publicKeys().isNotEmpty() } .forEach { rumor ->
?.forEach { event -> val id = rumor.roomId()
val newRoom = Room.new(rumor = event, userPubkey = userPubkey) val isFromMe = rumor.author() == userPubkey
val existingRoom = roomsMap[newRoom.id] val existing = roomsMap[id]
val createdAt = rumor.createdAt()
// Check if the room already exists // If the room is new or the current rumor is newer than the existing one
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
val rTag = SingleLetterTag.lowercase(Alphabet.R) // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val filter = Filter().kind(kind).pubkey(userPubkey) val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
.customTag(rTag, newRoom.id.toString()) val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
// Determine if it's an ongoing room } else if (isFromMe && existing.kind != RoomKind.Ongoing) {
val isOngoing = // If it's an older rumor but sent by the user, mark the room as Ongoing
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
// Append room to map
roomsMap[newRoom.id] =
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
} }
} }
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}") println("Failed to get chat rooms: ${e.message}")
return null return null
@@ -219,6 +227,8 @@ class MessageManager(private val nostr: Nostr) {
// Filter out events without public keys (receivers) // Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() } ?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: CancellationException) {
throw e
} 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)
} }
@@ -242,6 +252,8 @@ class MessageManager(private val nostr: Nostr) {
connectMsgRelays(event) connectMsgRelays(event)
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e) throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
} }
@@ -334,6 +346,8 @@ class MessageManager(private val nostr: Nostr) {
} }
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e) throw IllegalStateException("Failed to send message: ${e.message}", e)
} }

View File

@@ -1,7 +1,9 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientBuilder
@@ -30,24 +33,15 @@ import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
} }
class Nostr { class Nostr(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
var client: Client? = null var client: Client? = null
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -59,17 +53,18 @@ class Nostr {
private val isInitialized = MutableStateFlow(false) private val isInitialized = MutableStateFlow(false)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
suspend fun emitNewEvent(event: UnsignedEvent) { fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event) _newEvents.tryEmit(event)
} }
suspend fun init( suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
try { try {
if (isInitialized.value) return if (isInitialized.value) return
@@ -105,21 +100,14 @@ class Nostr {
} }
suspend fun waitUntilInitialized() { suspend fun waitUntilInitialized() {
isInitialized.first { it } withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
?: throw IllegalStateException("Nostr initialization timed out")
} }
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
relays.connectBootstrapRelays() relays.connectBootstrapRelays()
} }
suspend fun reconnect() {
relays.reconnect()
}
suspend fun disconnect() {
relays.disconnect()
}
suspend fun prune() { suspend fun prune() {
try { try {
client?.database()?.wipe() client?.database()?.wipe()
@@ -155,11 +143,11 @@ class Nostr {
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope val notifications = client?.notifications() ?: return@supervisorScope
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED) val giftWrapQueue = Channel<Event>(1024)
var processedCount = 0 var processedCount = 0
var eoseReceived = false var eoseReceived = false
launch(Dispatchers.Default) { launch(defaultDispatcher) {
for (event in giftWrapQueue) { for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event) val rumor = messages.extractRumor(event)
processedCount++ processedCount++
@@ -219,6 +207,7 @@ class Nostr {
KindStandard.INBOX_RELAYS -> { KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user // Get all gift wrap events for the current user
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
messages.updateSyncState { it.copy(isSyncing = true) }
messages.getUserMessages(msgRelayList = event) messages.getUserMessages(msgRelayList = event)
} }
} }

View File

@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
@@ -26,17 +27,28 @@ import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) { class ProfileManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
private val httpClient by lazy { HttpClient() }
private val _metadataUpdates = private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100) MutableSharedFlow<Pair<PublicKey, Metadata>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val metadataUpdates = _metadataUpdates.asSharedFlow() val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100) private val _contactListUpdates =
MutableSharedFlow<List<PublicKey>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val contactListUpdates = _contactListUpdates.asSharedFlow() val contactListUpdates = _contactListUpdates.asSharedFlow()
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
@@ -69,6 +81,8 @@ class ProfileManager(private val nostr: Nostr) {
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "user-metadata", closeOn = opts) client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
} }
@@ -78,11 +92,13 @@ class ProfileManager(private val nostr: Nostr) {
try { try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays) client?.sync(filter, relays)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) println("Failed to sync mutual contacts: ${e.message}")
} }
} }
@@ -138,19 +154,24 @@ class ProfileManager(private val nostr: Nostr) {
bio: String? = null, bio: String? = null,
picture: String? = null picture: String? = null
): Metadata { ): Metadata {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
try { try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() val currentUser =
val newRecord = record.copy( signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
// Get the latest metadata event
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
// Build a new metadata based on old records
val newMetadata = Metadata.fromRecord(
record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
)
// Send the new metadata event
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent( client?.sendEvent(
event = event, event = event,
target = SendEventTarget.broadcast(), target = SendEventTarget.broadcast(),
@@ -158,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return newMetadata return newMetadata
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e) throw IllegalStateException("Failed to update identity: ${e.message}", e)
} }
@@ -170,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first() ?: return null val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content()) Metadata.fromJson(event.content())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}") println("Failed to get latest metadata: ${e.message}")
null null
@@ -192,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get all cache metadata: ${e.message}") println("Failed to get all cache metadata: ${e.message}")
return emptyMap() return emptyMap()
@@ -211,11 +238,13 @@ class ProfileManager(private val nostr: Nostr) {
// Construct request target // Construct request target
val target = mutableMapOf<RelayUrl, List<Filter>>() val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter) target[RelayUrl.parse(relay)] = listOf(filter)
} }
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
} }
@@ -231,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
target = SendEventTarget.broadcast(), target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(), ackPolicy = AckPolicy.none(),
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to set contact list: ${e.message}", e) throw IllegalStateException("Failed to set contact list: ${e.message}", e)
} }
@@ -242,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
val bodyString: String = response.body() val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString) return Nip05Profile.fromJson(address, bodyString)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
} }
@@ -250,9 +283,11 @@ class ProfileManager(private val nostr: Nostr) {
suspend fun searchByAddress(query: String): PublicKey { suspend fun searchByAddress(query: String): PublicKey {
try { try {
val address = Nip05Address.parse(query) val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address) val profile = profileFromAddress(httpClient, address)
return profile.publicKey() return profile.publicKey()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e) throw IllegalStateException("Failed to search address: ${e.message}", e)
} }
@@ -288,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e) throw IllegalStateException("Failed to search nostr: ${e.message}", e)
} }
@@ -297,7 +334,7 @@ class ProfileManager(private val nostr: Nostr) {
try { try {
val filter = Filter().author(pubkey).limit(3u) val filter = Filter().author(pubkey).limit(3u)
val target = mutableMapOf<RelayUrl, List<Filter>>() val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter) target[RelayUrl.parse(relay)] = listOf(filter)
} }
@@ -307,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return events?.first()?.createdAt() return events?.first()?.createdAt()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get latest activity: ${e.message}", e) throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
} }
@@ -324,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
return pubkeys.contains(pubkey) return pubkeys.contains(pubkey)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }
@@ -345,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return contacts.toSet() return contacts.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }

View File

@@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) { class RelayManager(private val nostr: Nostr) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
NostrManager.BOOTSTRAP_RELAYS.forEach { url -> BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url)) client?.addRelay(RelayUrl.parse(url))
} }
NostrManager.INDEXER_RELAY.forEach { url -> INDEXER_RELAY.forEach { url ->
client?.addRelay( client?.addRelay(
url = RelayUrl.parse(url), url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip() capabilities = RelayCapabilities.gossip()
@@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) {
} }
suspend fun reconnect() { suspend fun reconnect() {
NostrManager.ALL_RELAYS.forEach { url -> ALL_RELAYS.forEach { url ->
try { try {
client?.relay(RelayUrl.parse(url)).let { relay -> client?.relay(RelayUrl.parse(url)).let { relay ->
if (relay != null) { if (relay != null) {
@@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) {
} }
suspend fun disconnect() { suspend fun disconnect() {
NostrManager.ALL_RELAYS.forEach { url -> ALL_RELAYS.forEach { url ->
try { try {
client?.disconnectRelay(RelayUrl.parse(url)) client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) { } catch (e: Exception) {
@@ -104,6 +118,32 @@ class RelayManager(private val nostr: Nostr) {
return msgRelayList return msgRelayList
} }
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
suspend fun setMsgRelays(urls: List<RelayUrl>) { suspend fun setMsgRelays(urls: List<RelayUrl>) {
try { try {
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
@@ -154,30 +194,4 @@ class RelayManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
} }
} }
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
} }

View File

@@ -29,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
/** /**
* Switch to a new signer. * Switch to a new signer.
*/ */
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock { suspend fun switch(newSigner: AsyncNostrSigner) {
val pubkey = try { val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
withTimeoutOrNull(20.seconds) { ?: throw IllegalStateException("Failed to get public key from signer")
newSigner.getPublicKeyAsync()
} mutex.withLock {
} catch (e: Exception) { signer = newSigner
throw IllegalStateException("Failed to get public key from signer", e) _publicKeyFlow.value = pubkey
} }
signer = newSigner
_publicKeyFlow.value = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {

View File

@@ -0,0 +1,597 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountRepository(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _userRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = _userRelayList.asStateFlow()
private val _userMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val userMsgRelayList: StateFlow<List<RelayUrl>> = _userMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
observeSignerState()
observeContactList()
}
private fun observeSignerState() {
scope.launch {
nostr.signer.publicKeyFlow
.filterNotNull()
.distinctUntilChanged()
.collectLatest { pubkey ->
// Refresh metadata
nostr.profiles.getUserMetadata()
// Verify messaging relays exist (wait up to 10s for initial fetch)
val relays = withTimeoutOrNull(10.seconds) {
var list = nostr.relays.getMsgRelays(pubkey)
while (list.isEmpty()) {
delay(500.milliseconds)
list = nostr.relays.getMsgRelays(pubkey)
}
list
} ?: emptyList()
// Automatically update the warning state
_isRelayListEmpty.value = relays.isEmpty()
}
}
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _contactList.value) return@launch
try {
val updated = _contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeContact(publicKey: PublicKey) {
scope.launch {
if (publicKey !in _contactList.value) return@launch
try {
val updated = _contactList.value - publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userRelayList.value = nostr.relays.getRelayList(user)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userMsgRelayList.value = nostr.relays.getMsgRelays(user)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun userLatestMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}

View File

@@ -0,0 +1,259 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
data class ChatState(
val rooms: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
val chatRooms = state
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState
.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state
.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init {
scope.launch(defaultDispatcher) {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
}
}
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
// Check if the room already exists
val id = rumor.roomId()
val existingRoom = _state.value.rooms[id]
// If the room already exists, return its ID
if (existingRoom != null) {
return existingRoom.id
}
// Create a room from the rumor event
val room = Room.new(rumor, currentUser)
// Update the chat rooms state
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun refreshChatRooms() {
scope.launch(defaultDispatcher) {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
scope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun chatRoomConnect(roomId: Long) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
nostr.messages.chatRoomConnect(members.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
return
}
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
scope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${e.message}")
}
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState ->
val room = currentState.rooms[roomId] ?: return@update currentState
val updatedRoom = room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
}

View File

@@ -1,13 +0,0 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -3,6 +3,7 @@ package su.reya.coop.repository
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.CancellationException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
@@ -31,6 +32,8 @@ class MediaRepository {
signer = signer, signer = signer,
) )
descriptor?.url descriptor?.url
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Upload failed: ${e.message}") println("Upload failed: ${e.message}")
null null

View File

@@ -1,8 +0,0 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -0,0 +1,72 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
class AccountViewModel(
private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.userRelayList
val userMsgRelayList: StateFlow<List<RelayUrl>> = repository.userMsgRelayList
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) =
repository.importIdentity(secret, password)
fun connectExternalSigner() = repository.connectExternalSigner()
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) = repository.createIdentity(name, bio, picture, contentType)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) = repository.updateProfile(name, bio, picture, contentType)
fun resetInternalState() = repository.resetInternalState()
fun addContact(address: String) = repository.addContact(address)
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
repository.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
repository.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
repository.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
repository.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
repository.mutualContacts(pubkey, onResult)
fun dismissRelayWarning() = repository.dismissRelayWarning()
fun refetchMsgRelays() = repository.refetchMsgRelays()
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
}

View File

@@ -1,259 +0,0 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds
data class AuthState(
val isBusy: Boolean = false,
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
)
class AuthViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(3.seconds) {
secretStore.get(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
_state.update { it.copy(isBusy = true) }
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
_state.update { it.copy(isBusy = false, signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) {
return Keys.parse(secret)
}
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
}
private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null)
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
try {
val signer = createSigner(secret)
if (secret.startsWith("bunker://")) {
showError("Please approve the connection.")
}
return signer.getPublicKeyAsync()
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun importIdentity(secret: String) {
_state.update { it.copy(isBusy = true) }
try {
val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_state.update { it.copy(isBusy = true) }
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(
KEY_USER_SIGNER,
"nip55://${result.packageName}/${result.pubkey.toHex()}"
)
// Update local states
_state.update { it.copy(signerRequired = false, isBusy = false) }
} catch (e: Exception) {
_state.update { it.copy(isBusy = false) }
showError("Notice: ${e.message}")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
_state.update { it.copy(isBusy = true) }
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(isBusy = false, signerRequired = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
_state.update { it.copy(isBusy = false) }
}
}
}

View File

@@ -1,10 +0,0 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository
abstract class BaseViewModel : ViewModel() {
protected fun showError(message: String) {
ErrorRepository.showError(message)
}
}

View File

@@ -0,0 +1,74 @@
package su.reya.coop.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId
class ChatScreenViewModel(
val id: Long,
screening: Boolean,
accountRepository: AccountRepository,
private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>()
init {
loadMessages()
connect()
observeNewEvents()
}
private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() })
loading = false
}
}
private fun connect() {
chatRepository.chatRoomConnect(id)
}
private fun observeNewEvents() {
viewModelScope.launch {
chatRepository.newEvents.collect { event ->
if (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) {
messages.add(0, event)
}
} else {
newOtherMessages++
}
}
}
}
fun sendMessage(text: String, replyTo: EventId? = null) {
val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
chatRepository.sendMessage(id, text, replyToList)
}
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
}

View File

@@ -1,260 +1,21 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.nostr.Nostr import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.ChatState
import su.reya.coop.roomId
data class ChatState( class ChatViewModel(
val rooms: Set<Room> = emptySet(), private val repository: ChatRepository,
val isSyncing: Boolean = false, ) : ViewModel(), ErrorHost by repository {
val isPartialProcessedGiftWrap: Boolean = false, val state: StateFlow<ChatState> = repository.state
) val chatRooms: StateFlow<List<Room>> = repository.chatRooms
val isSyncing: StateFlow<Boolean> = repository.isSyncing
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
private val mediaRepository = MediaRepository() fun refreshChatRooms() = repository.refreshChatRooms()
fun resetInternalState() = repository.resetInternalState()
private val _state = MutableStateFlow(ChatState())
val state = combine(
_state,
nostr.messages.messageSyncState
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
val chatRooms = state.map { it.rooms }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
viewModelScope.launch {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 10 messages OR when sync is fully done
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update {
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else {
updateRoomList(roomId, event)
}
_newEvents.emit(event)
}
}
// Initial load of rooms
refreshChatRooms()
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
// Check if the room already exists
val id = rumor.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
// If the room already exists, return its ID
if (existingRoom != null) {
return existingRoom.id
}
// Create a room from the rumor event
val room = Room.new(rumor, currentUser)
// Update the chat rooms state
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms.firstOrNull { it.id == id }
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val merged = currentState.rooms.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
currentState.copy(rooms = merged.values.sortedDescending().toSet())
}
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.messages.getChatRoomMessages(roomId)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
fun chatRoomConnect(roomId: Long) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
nostr.messages.chatRoomConnect(members.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
}
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState ->
val updatedRooms = currentState.rooms.map { room ->
if (room.id == roomId) {
room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptySet(),
isPartialProcessedGiftWrap = false,
)
}
}
} }

View File

@@ -0,0 +1,28 @@
package su.reya.coop.viewmodel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
interface ErrorHost {
val errorEvents: SharedFlow<String>
fun showError(message: String)
}
fun createErrorHost(): ErrorHost = ErrorHostImpl()
private class ErrorHostImpl : ErrorHost {
private val _errorEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.SUSPEND
)
override val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
override fun showError(message: String) {
_errorEvents.tryEmit(message)
}
}

View File

@@ -1,446 +0,0 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class NostrAppState(
val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false,
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> =
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays()
// Get all local stored metadata
getCacheMetadata()
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
private suspend fun runObserver() = coroutineScope {
// Observe contact list updates
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observe metadata updates
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
// Get the first pubkey
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
// Get current time
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
// Get the next pubkey
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the pubkey if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
// Update the metadata state
updateMetadata(pubkey, Profile(pubkey, metadata))
// Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey)
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
// Small delay to ensure all relays are connected
delay(2.seconds)
// Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
viewModelScope.launch {
profilesMutex.withLock {
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
}
}
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) requestMetadata(pubkey)
return flow.asStateFlow()
}
fun resetInternalState() {
_contactList.value = emptySet()
_appState.update {
it.copy(
isRelayListEmpty = false,
)
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun refetchMsgRelays() {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun addInboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addOutboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
suspend fun addMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
private suspend fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addContact(address: String): Boolean {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return false
}
return run {
newContact(pubkey)
true
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.profiles.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.profiles.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.profiles.verifyActivity(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.profiles.verifyContact(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.profiles.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
}

View File

@@ -0,0 +1,138 @@
package su.reya.coop.viewmodel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.milliseconds
class ProfileCache(
private val nostr: Nostr,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
init {
scope.launch {
try {
runObserver()
} catch (e: Exception) {
showError("Metadata observer failed: ${e.message}")
}
}
scope.launch {
try {
runMetadataBatching()
} catch (e: Exception) {
showError("Metadata batching failed: ${e.message}")
}
}
scope.launch {
try {
nostr.waitUntilInitialized()
getCachedMetadata()
} catch (e: Exception) {
showError("Failed to load initial cache: ${e.message}")
}
}
}
private suspend fun runObserver() {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
private suspend fun runMetadataBatching() {
nostr.waitUntilInitialized()
while (true) {
val firstKey = metadataRequestChannel.receive()
val batch = mutableSetOf(firstKey)
while (batch.size < 10) {
val nextKey =
withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() }
?: break
batch.add(nextKey)
}
try {
nostr.profiles.fetchMetadataBatch(batch.toList())
} catch (e: Exception) {
// Allow these keys to be requested again since the fetch failed
seenPublicKeys.update { it - batch }
println("Failed to fetch metadata batch: ${e.message}")
}
}
}
private suspend fun getCachedMetadata() {
val cache = nostr.profiles.getAllCacheMetadata()
cache.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
private fun requestMetadata(pubkey: PublicKey) {
var added = false
seenPublicKeys.update { current ->
if (current.contains(pubkey)) {
added = false
current
} else {
added = true
current + pubkey
}
}
if (added) metadataRequestChannel.trySend(pubkey)
}
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
profiles.update { current ->
val flow = current[pubkey] ?: MutableStateFlow<Profile?>(null)
flow.value = profile
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
}
seenPublicKeys.update { it + pubkey }
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val currentMap = profiles.value
val existingFlow = currentMap[pubkey]
if (existingFlow != null) {
if (existingFlow.value == null) requestMetadata(pubkey)
return existingFlow.asStateFlow()
}
val newFlow = MutableStateFlow<Profile?>(null)
var resultFlow = newFlow
profiles.update { prev ->
if (prev.containsKey(pubkey)) {
resultFlow = prev[pubkey]!!
prev
} else {
prev + (pubkey to newFlow)
}
}
if (resultFlow.value == null) requestMetadata(pubkey)
return resultFlow.asStateFlow()
}
}