Compare commits
24 Commits
v0.2.2
...
6bb4ef2805
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bb4ef2805 | |||
| e4e73da229 | |||
| 255c840847 | |||
| 9defad522c | |||
| 47196dbea4 | |||
| 30191170f1 | |||
| 1485a767ed | |||
| 0e789b5d91 | |||
| 410615bd89 | |||
| a5951e03cf | |||
| 175c06a1a8 | |||
| c7a646ae73 | |||
| 7a44e836a9 | |||
| 4dae4045ae | |||
| 9df4789b62 | |||
| ea7e3fd3ed | |||
| a76c8d117b | |||
| 9ed29c90ba | |||
| 80c6426d27 | |||
| 801347ccb1 | |||
| 19daea119d | |||
| 8b2f0faa59 | |||
| a2d28ecc40 | |||
| cd5a393a01 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
.artifacts/
|
||||
@@ -25,8 +25,8 @@ kotlin {
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||
}
|
||||
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.2.2"
|
||||
versionName = "0.2.5"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting">
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -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,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,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>
|
||||
@@ -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="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -26,7 +25,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@@ -34,7 +36,10 @@ import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
@@ -47,30 +52,19 @@ import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.viewmodels.AccountViewModel
|
||||
import su.reya.coop.viewmodels.AppViewModel
|
||||
import su.reya.coop.viewmodels.ChatViewModel
|
||||
import su.reya.coop.viewmodels.ProfileViewModel
|
||||
import su.reya.coop.viewmodels.RelayViewModel
|
||||
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.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
||||
error("No AppViewModel provided")
|
||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||
error("No ProfileCache provided")
|
||||
}
|
||||
|
||||
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
||||
error("No AccountViewModel provided")
|
||||
}
|
||||
|
||||
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||
error("No ChatViewModel provided")
|
||||
}
|
||||
|
||||
val LocalProfileViewModel = staticCompositionLocalOf<ProfileViewModel> {
|
||||
error("No ProfileViewModel provided")
|
||||
}
|
||||
|
||||
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
|
||||
error("No RelayViewModel provided")
|
||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||
error("No Settings provided")
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
@@ -88,12 +82,39 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun App(
|
||||
appViewModel: AppViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
profileViewModel: ProfileViewModel,
|
||||
relayViewModel: RelayViewModel,
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
) {
|
||||
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
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
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 settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val backStack = rememberNavBackStack(Screen.Home)
|
||||
@@ -101,21 +122,27 @@ fun App(
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
|
||||
// Get the signer required state
|
||||
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val signerRequired = accountState.signerRequired
|
||||
|
||||
// Get the settings
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Check if dark theme enabled
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val darkMode = when (settings.theme) {
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
Theme.System -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||
context
|
||||
)
|
||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
// When dark mode is enabled, use the dark color scheme
|
||||
darkMode -> darkColorScheme()
|
||||
@@ -123,13 +150,21 @@ fun App(
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size > 1) {
|
||||
navigator.goBack()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
appViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
launch {
|
||||
accountViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
chatViewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
profileCache.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +173,7 @@ fun App(
|
||||
fun handleIntent(intent: Intent) {
|
||||
val screen = Screen.fromIntent(intent)
|
||||
// Prevent pushing the same screen
|
||||
if (screen != null && backStack.lastOrNull() != screen) {
|
||||
if ((screen != null) && (backStack.lastOrNull() != screen)) {
|
||||
navigator.navigate(screen)
|
||||
}
|
||||
}
|
||||
@@ -171,11 +206,8 @@ fun App(
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAppViewModel provides appViewModel,
|
||||
LocalAccountViewModel provides accountViewModel,
|
||||
LocalChatViewModel provides chatViewModel,
|
||||
LocalProfileViewModel provides profileViewModel,
|
||||
LocalRelayViewModel provides relayViewModel,
|
||||
LocalProfileCache provides profileCache,
|
||||
LocalSettings provides settings,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
@@ -195,43 +227,62 @@ fun App(
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen()
|
||||
HomeScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen()
|
||||
RequestListScreen(chatViewModel)
|
||||
}
|
||||
entry<Screen.Onboarding> {
|
||||
OnboardingScreen()
|
||||
OnboardingScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Import> {
|
||||
ImportScreen()
|
||||
ImportScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.NewIdentity> {
|
||||
NewIdentityScreen()
|
||||
NewIdentityScreen(accountViewModel)
|
||||
}
|
||||
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> {
|
||||
NewChatScreen()
|
||||
NewChatScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Profile> { key ->
|
||||
ProfileScreen(pubkey = key.pubkey)
|
||||
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
|
||||
}
|
||||
entry<Screen.UpdateProfile> {
|
||||
UpdateProfileScreen()
|
||||
UpdateProfileScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Scan> {
|
||||
ScanScreen()
|
||||
}
|
||||
entry<Screen.MyQr> {
|
||||
MyQrScreen()
|
||||
MyQrScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.ContactList> {
|
||||
ContactListScreen()
|
||||
ContactListScreen(accountViewModel, chatViewModel)
|
||||
}
|
||||
entry<Screen.Relay> {
|
||||
RelayScreen()
|
||||
RelayScreen(accountViewModel)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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.KeyProperties
|
||||
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.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class SecretEntry(
|
||||
val encrypted: String,
|
||||
val iv: String
|
||||
)
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
data class SecretEntry(val encrypted: String, val iv: String)
|
||||
|
||||
class SecretCrypto {
|
||||
private val keyAlias = "coop"
|
||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(content: String): SecretEntry {
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
// Encrypt content
|
||||
val encrypted = cipher.doFinal(content.toByteArray())
|
||||
val iv = cipher.iv
|
||||
|
||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
// Decrypt content
|
||||
val plaintext = cipher.doFinal(encrypted)
|
||||
|
||||
return String(plaintext, StandardCharsets.UTF_8)
|
||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||
val existingKey = keyStore.getKey(keyAlias, null)
|
||||
|
||||
// Return existing key if available
|
||||
if (existingKey is SecretKey) return existingKey
|
||||
|
||||
// Construct a new key generator
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||
|
||||
// Initialize key generation parameters
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
// Generate a new key
|
||||
keyGenerator.init(spec)
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ExternalSignerLauncher {
|
||||
class ExternalSignerLauncher(
|
||||
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||
) {
|
||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||
private val mutex = Mutex()
|
||||
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
|
||||
}
|
||||
|
||||
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||
withContext(Dispatchers.Main) {
|
||||
withContext(mainDispatcher) {
|
||||
val deferred = CompletableDeferred<ActivityResult>()
|
||||
pendingResult = deferred
|
||||
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||
|
||||
@@ -8,25 +8,14 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import kotlinx.coroutines.MainScope
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.viewmodels.AccountViewModel
|
||||
import su.reya.coop.viewmodels.AppViewModel
|
||||
import su.reya.coop.viewmodels.ChatViewModel
|
||||
import su.reya.coop.viewmodels.ProfileViewModel
|
||||
import su.reya.coop.viewmodels.RelayViewModel
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -34,36 +23,32 @@ class MainActivity : ComponentActivity() {
|
||||
val externalSignerLauncher = ExternalSignerLauncher()
|
||||
}
|
||||
|
||||
private val factory by lazy {
|
||||
object : ViewModelProvider.Factory {
|
||||
private val secretStore = SecretStore(this@MainActivity)
|
||||
private val androidSigner =
|
||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
private val appVM = AppViewModel(NostrManager.instance, secretStore)
|
||||
private val profileVM = ProfileViewModel(NostrManager.instance, appVM)
|
||||
private val chatVM = ChatViewModel(NostrManager.instance, appVM)
|
||||
private val accountVM =
|
||||
AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner)
|
||||
private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM)
|
||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||
private val scope = MainScope()
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when {
|
||||
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM
|
||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM
|
||||
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM
|
||||
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
} as T
|
||||
}
|
||||
}
|
||||
private val settingsRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
SettingsRepository(storage, scope)
|
||||
}
|
||||
|
||||
private val appViewModel: AppViewModel by viewModels { factory }
|
||||
private val accountViewModel: AccountViewModel by viewModels { factory }
|
||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||
private val profileViewModel: ProfileViewModel by viewModels { factory }
|
||||
private val relayViewModel: RelayViewModel by viewModels { factory }
|
||||
private val accountRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
AccountRepository(
|
||||
NostrManager.instance,
|
||||
storage,
|
||||
mediaRepository,
|
||||
settingsRepository,
|
||||
scope,
|
||||
androidSigner
|
||||
)
|
||||
}
|
||||
|
||||
private val chatRepository by lazy {
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
@@ -102,36 +87,20 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
accountViewModel.signerRequired.value == null
|
||||
accountRepository.state.value.signerRequired == null
|
||||
}
|
||||
|
||||
// Bind the lifecycle of the ViewModels
|
||||
val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
appViewModel.viewModelScope.launch {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { chatViewModel.bindObservers() }
|
||||
launch { profileViewModel.bindObservers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
relayViewModel.observeSignerAndCheckRelays()
|
||||
|
||||
setContent {
|
||||
App(
|
||||
appViewModel = appViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
profileViewModel = profileViewModel,
|
||||
relayViewModel = relayViewModel,
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -21,13 +22,14 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.nostr.roomId
|
||||
import java.io.File
|
||||
|
||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
private val nostr by lazy { NostrManager.instance }
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.nostr.Room
|
||||
import su.reya.coop.nostr.formatAsGroupHeader
|
||||
import su.reya.coop.nostr.humanReadable
|
||||
import su.reya.coop.nostr.roomId
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.shared.nameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
// Get chat room by ID
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||
|
||||
val displayName by remember(room, currentUser) {
|
||||
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
|
||||
}.collectAsStateWithLifecycle("Loading...")
|
||||
|
||||
val picture by remember(room) {
|
||||
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||
var requireScreening by remember { mutableStateOf(screening) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
|
||||
val groupedMessages = remember(messages.toList()) {
|
||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||
}
|
||||
|
||||
LaunchedEffect(id) {
|
||||
// Start loading spinner
|
||||
loading = true
|
||||
|
||||
// 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) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room?.members?.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BadgedBox(
|
||||
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 ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (requireScreening) {
|
||||
room?.let { ScreenerCard(it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
items = messagesInGroup,
|
||||
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||
) {
|
||||
ChatMessage(it)
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { navigator.goBack() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Reject",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { requireScreening = false },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Accept",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
chatViewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenerCard(room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||
val picture = profile?.picture
|
||||
|
||||
LaunchedEffect(pubkey) {
|
||||
scope.launch {
|
||||
// Check contact
|
||||
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
||||
// Get mutual contacts
|
||||
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||
// Get the last activity
|
||||
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = pubkey.short(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (isContact) "Contact" else "Not a contact",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check_circle),
|
||||
contentDescription = "Warning",
|
||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
rumor: UnsignedEvent
|
||||
) {
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||
val isMine = rumor.author() == currentUser
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val id = rumor.id()
|
||||
if (id != null) {
|
||||
val sent = chatViewModel.isMessageSent(id)
|
||||
println("Sent: $sent")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = rumor.content(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ package su.reya.coop.screens
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -20,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
@@ -28,11 +33,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,26 +53,30 @@ import coop.composeapp.generated.resources.ic_check
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactListScreen() {
|
||||
fun ContactListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
@@ -128,43 +135,54 @@ fun ContactListScreen() {
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (contactList.isNotEmpty()) {
|
||||
contactList.forEachIndexed { index, pubkey ->
|
||||
if (contactList.isNotEmpty()) {
|
||||
val contacts = remember(contactList) { contactList.toList() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
itemsIndexed(contacts) { index, pubkey ->
|
||||
ContactListItem(
|
||||
pubkey = pubkey,
|
||||
index = index,
|
||||
total = contactList.size,
|
||||
onClick = {})
|
||||
total = contacts.size,
|
||||
onClick = {
|
||||
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(room))
|
||||
},
|
||||
onLongClick = {
|
||||
contactToDelete = pubkey
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No contacts yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your contacts will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "No contacts yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your contacts will appear here",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,21 +190,47 @@ fun ContactListScreen() {
|
||||
)
|
||||
|
||||
if (openAddContactDialog) {
|
||||
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||
AddContactDialog(
|
||||
accountViewModel = accountViewModel,
|
||||
onDismissRequest = { openAddContactDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (contactToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { contactToDelete = null },
|
||||
title = { Text("Delete Contact") },
|
||||
text = { Text("Are you sure you want to remove this contact?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
contactToDelete?.let { accountViewModel.removeContact(it) }
|
||||
contactToDelete = null
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { contactToDelete = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
fun AddContactDialog(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var contact by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
@@ -224,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
val success = profileViewModel.addContact(contact)
|
||||
if (success) onDismissRequest()
|
||||
}
|
||||
accountViewModel.addContact(contact)
|
||||
onDismissRequest()
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check),
|
||||
@@ -282,34 +324,31 @@ fun ContactListItem(
|
||||
index: Int,
|
||||
total: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
SegmentedListItem(
|
||||
onClick = onClick,
|
||||
onLongClick = { profileViewModel.removeContact(pubkey) },
|
||||
onLongClick = onLongClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = total
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name,
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
supportingContent = { Text(pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -60,7 +61,6 @@ import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -91,53 +91,47 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalRelayViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.nostr.Room
|
||||
import su.reya.coop.nostr.RoomKind
|
||||
import su.reya.coop.nostr.ago
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.shared.nameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.uiStateFlow
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
||||
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty = accountState.isRelayListEmpty
|
||||
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||
|
||||
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
@@ -164,8 +158,8 @@ fun HomeScreen() {
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
chatViewModel.getChatRooms()
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
chatViewModel.refreshChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
@@ -218,8 +212,8 @@ fun HomeScreen() {
|
||||
// User
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
@@ -257,7 +251,9 @@ fun HomeScreen() {
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||
AnimatedVisibility(
|
||||
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -289,7 +285,7 @@ fun HomeScreen() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { appViewModel.dismissNotificationBanner() },
|
||||
onClick = { accountViewModel.dismissNotificationBanner() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Maybe later")
|
||||
@@ -328,11 +324,9 @@ fun HomeScreen() {
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
isRefreshing = true
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
@@ -382,10 +376,14 @@ fun HomeScreen() {
|
||||
}
|
||||
|
||||
items(ongoing, key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.animateItem()
|
||||
) {
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,14 +398,6 @@ fun HomeScreen() {
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val pubkey = accountViewModel.nostr.signer.currentUser
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
|
||||
val userName =
|
||||
userProfile?.asRecord()?.displayName
|
||||
?: userProfile?.asRecord()?.name
|
||||
?: "No name"
|
||||
|
||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
@@ -432,8 +422,8 @@ fun HomeScreen() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
picture = userProfile?.picture,
|
||||
description = userProfile?.name ?: "No name",
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
@@ -443,7 +433,7 @@ fun HomeScreen() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = userName,
|
||||
text = userProfile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
@@ -454,16 +444,15 @@ fun HomeScreen() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pubkey?.let {
|
||||
userProfile?.publicKey?.let {
|
||||
val bech32 = it.toBech32()
|
||||
val data =
|
||||
ClipData.newPlainText(bech32, bech32)
|
||||
val data = ClipData.newPlainText(bech32, bech32)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = shortPubkey)
|
||||
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
@@ -479,7 +468,11 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
BottomMenuList(onDismiss = dismissAndRun)
|
||||
BottomMenuList(
|
||||
onDismiss = dismissAndRun,
|
||||
accountViewModel = accountViewModel,
|
||||
chatViewModel = chatViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,7 +480,7 @@ fun HomeScreen() {
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
||||
onDismissRequest = { accountViewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
@@ -591,15 +584,9 @@ fun HomeScreen() {
|
||||
TextButton(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
isBusy = true
|
||||
try {
|
||||
relayViewModel.refetchMsgRelays(currentUser)
|
||||
} catch (e: Exception) {
|
||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||
}
|
||||
isBusy = false
|
||||
}
|
||||
isBusy = true
|
||||
accountViewModel.refetchMsgRelays()
|
||||
isBusy = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -613,10 +600,8 @@ fun HomeScreen() {
|
||||
Button(
|
||||
enabled = !isBusy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
relayViewModel.useDefaultMsgRelayList()
|
||||
sheetState.hide()
|
||||
}
|
||||
accountViewModel.useDefaultMsgRelayList()
|
||||
scope.launch { sheetState.hide() }
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -637,34 +622,34 @@ fun HomeScreen() {
|
||||
@Composable
|
||||
fun NewRequests(requests: List<Room>) {
|
||||
val navigator = LocalNavigator.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
val total = requests.size
|
||||
val firstRoom = requests.getOrNull(0)
|
||||
val secondRoom = requests.getOrNull(1)
|
||||
|
||||
val firstName by remember(firstRoom) {
|
||||
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("Loading...")
|
||||
val firstRoomState by remember(firstRoom?.id) {
|
||||
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val secondName by remember(secondRoom) {
|
||||
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||
}.collectAsStateWithLifecycle("")
|
||||
val secondRoomState by remember(secondRoom?.id) {
|
||||
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val supportingText = when {
|
||||
total == 1 && firstRoom != null -> {
|
||||
val message = firstRoom.lastMessage ?: ""
|
||||
"$firstName: $message"
|
||||
"${firstRoomState.name}: $message"
|
||||
}
|
||||
|
||||
total == 2 -> {
|
||||
"$firstName and $secondName"
|
||||
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||
}
|
||||
|
||||
total > 2 -> {
|
||||
val othersCount = total - 2
|
||||
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||
"$firstName, $secondName and $othersText"
|
||||
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||
}
|
||||
|
||||
else -> ""
|
||||
@@ -720,14 +705,14 @@ fun NewRequests(requests: List<Room>) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
||||
val profileCache = LocalProfileCache.current
|
||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Avatar(picture = picture, description = displayName)
|
||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
@@ -735,7 +720,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
@@ -765,10 +750,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit
|
||||
onDismiss: (suspend () -> Unit) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel,
|
||||
) {
|
||||
val navigator = LocalNavigator.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
@@ -798,7 +784,14 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { onDismiss { accountViewModel.logout() } },
|
||||
onClick = {
|
||||
onDismiss {
|
||||
accountViewModel.logout(onLogout = {
|
||||
accountViewModel.resetInternalState()
|
||||
chatViewModel.resetInternalState()
|
||||
})
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -30,75 +29,49 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ImportScreen() {
|
||||
fun ImportScreen(viewModel: AccountViewModel) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
// Get metadata when pubkey changes
|
||||
val metadata by remember(pubkey) {
|
||||
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||
val picture = profile?.picture
|
||||
var password by remember { mutableStateOf("") }
|
||||
var requirePassword by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec1")) {
|
||||
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
@@ -108,13 +81,26 @@ fun ImportScreen() {
|
||||
}.onSuccess {
|
||||
secret = result
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
// Clear the nav state
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -154,35 +140,6 @@ fun ImportScreen() {
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.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 = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -193,7 +150,7 @@ fun ImportScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -210,9 +167,9 @@ fun ImportScreen() {
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
enabled = !isBusy,
|
||||
enabled = !accountState.isImporting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
@@ -244,32 +201,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))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (pubkey == null) {
|
||||
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
} else {
|
||||
// Import the identity
|
||||
accountViewModel.importIdentity(secret)
|
||||
// Navigate to the home screen
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.importIdentity(secret, password)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = secret.isNotBlank() && !isBusy,
|
||||
enabled = secret.isNotBlank() && !accountState.isImporting,
|
||||
) {
|
||||
if (isBusy) {
|
||||
if (accountState.isImporting) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,22 +13,23 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun MyQrScreen() {
|
||||
fun MyQrScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -59,7 +60,7 @@ fun MyQrScreen() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
||||
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,26 +55,28 @@ import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewChatScreen() {
|
||||
fun NewChatScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
|
||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val contactList = accountState.contactList
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
@@ -96,14 +98,16 @@ fun NewChatScreen() {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else if (query.contains("@")) {
|
||||
val pubkey = profileViewModel.searchByAddress(query)
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
accountViewModel.searchByAddress(query) { pubkey ->
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val results = profileViewModel.searchByNostr(query)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
accountViewModel.searchByNostr(query) { results ->
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
}
|
||||
}
|
||||
|
||||
query = ""
|
||||
@@ -182,8 +186,8 @@ fun NewChatScreen() {
|
||||
)
|
||||
},
|
||||
text = { Text("Next") },
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -290,13 +294,9 @@ fun ReceiverChip(
|
||||
pubkey: PublicKey,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
InputChip(
|
||||
@@ -304,7 +304,7 @@ fun ReceiverChip(
|
||||
onClick = onRemove,
|
||||
label = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "No name",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -312,8 +312,8 @@ fun ReceiverChip(
|
||||
},
|
||||
avatar = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "No name",
|
||||
size = 24.dp
|
||||
)
|
||||
},
|
||||
@@ -376,13 +376,9 @@ fun ContactListItem(
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val profileCache = LocalProfileCache.current
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsState(initial = null)
|
||||
|
||||
SegmentedListItem(
|
||||
selected = isSelected,
|
||||
@@ -394,15 +390,15 @@ fun ContactListItem(
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
picture = profile?.picture,
|
||||
description = profile?.name ?: "",
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,35 +2,25 @@ package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewIdentityScreen() {
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
fun NewIdentityScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Create a new identity",
|
||||
buttonLabel = "Continue",
|
||||
isBusy = isBusy,
|
||||
isBusy = accountState.isImporting,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -41,25 +43,33 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen() {
|
||||
fun OnboardingScreen(viewModel: AccountViewModel) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Navigate to Home on successful external signer connection
|
||||
LaunchedEffect(accountState.signerRequired) {
|
||||
if (accountState.signerRequired == false) {
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
val expressiveFont = getExpressiveFontFamily()
|
||||
|
||||
@@ -157,15 +167,12 @@ fun OnboardingScreen() {
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (accountViewModel.isExternalSignerAvailable()) {
|
||||
try {
|
||||
accountViewModel.connectExternalSigner()
|
||||
navigator.navigate(Screen.Home)
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
} else {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
// Connect to the external signer
|
||||
// TODO: show all available signers?
|
||||
viewModel.connectExternalSigner()
|
||||
} else {
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "External signer not installed. Please install Amber or alternatives.",
|
||||
actionLabel = "Install",
|
||||
|
||||
@@ -44,40 +44,41 @@ import coop.composeapp.generated.resources.ic_share
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(pubkey: String) {
|
||||
fun ProfileScreen(
|
||||
pubkey: String,
|
||||
chatViewModel: ChatViewModel
|
||||
) {
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
val metadata = profile?.metadata?.asRecord()
|
||||
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||
val picture = metadata?.picture
|
||||
|
||||
val details = remember(profile) {
|
||||
listOf(
|
||||
"Username:" to (profile?.name ?: "None"),
|
||||
"Website:" to (profile?.website ?: "None"),
|
||||
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||
"Username:" to (metadata?.name ?: "None"),
|
||||
"Website:" to (metadata?.website ?: "None"),
|
||||
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
@@ -161,7 +162,8 @@ fun ProfileScreen(pubkey: String) {
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||
val roomId =
|
||||
chatViewModel.createChatRoom(listOf(pubkey))
|
||||
navigator.navigate(Screen.Chat(roomId))
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_check
|
||||
@@ -66,15 +67,14 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalRelayViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RelayScreen() {
|
||||
fun RelayScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||
@@ -96,8 +96,26 @@ fun RelayScreen() {
|
||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
relayList.putAll(relayViewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
||||
viewModel.loadCurrentUserRelayList()
|
||||
viewModel.loadCurrentUserMsgRelayList()
|
||||
}
|
||||
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadedRelayList = accountState.userRelayList
|
||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||
|
||||
LaunchedEffect(loadedRelayList) {
|
||||
if (loadedRelayList.isNotEmpty()) {
|
||||
relayList.clear()
|
||||
relayList.putAll(loadedRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(loadedMsgRelayList) {
|
||||
if (loadedMsgRelayList.isNotEmpty()) {
|
||||
msgRelayList.clear()
|
||||
msgRelayList.addAll(loadedMsgRelayList)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -296,6 +314,7 @@ fun RelayScreen() {
|
||||
|
||||
if (openAddRelayDialog) {
|
||||
AddRelayDialog(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { openAddRelayDialog = false },
|
||||
onMsgRelayAdded = { newRelay ->
|
||||
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||
@@ -314,20 +333,16 @@ fun RelayScreen() {
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (msgRelayList.size == 1) {
|
||||
if (msgRelayList.size == 1) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||
relayToDelete = null
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
relayViewModel.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")
|
||||
@@ -345,14 +360,12 @@ fun RelayScreen() {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun AddRelayDialog(
|
||||
viewModel: AccountViewModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||
) {
|
||||
val relayViewModel = LocalRelayViewModel.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var relayAddress by remember { mutableStateOf("") }
|
||||
@@ -397,26 +410,24 @@ fun AddRelayDialog(
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
relayViewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
|
||||
"Inbox" -> {
|
||||
relayViewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
|
||||
"Outbox" -> {
|
||||
relayViewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
if (!isError) {
|
||||
when (selected) {
|
||||
"Messaging" -> {
|
||||
viewModel.addMsgRelay(relayAddress)
|
||||
onMsgRelayAdded(relayAddress)
|
||||
}
|
||||
|
||||
"Inbox" -> {
|
||||
viewModel.addInboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||
}
|
||||
|
||||
"Outbox" -> {
|
||||
viewModel.addOutboxRelay(relayAddress)
|
||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
onDismissRequest()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalChatViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.nostr.RoomKind
|
||||
import su.reya.coop.viewmodel.ChatViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RequestListScreen() {
|
||||
fun RequestListScreen(viewModel: ChatViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val chatViewModel = LocalChatViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
|
||||
// Get all request rooms
|
||||
val requests = remember(chatRooms) {
|
||||
@@ -101,11 +97,9 @@ fun RequestListScreen() {
|
||||
isRefreshing = isRefreshing,
|
||||
state = pullToRefreshState,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
chatViewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
isRefreshing = true
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
|
||||
@@ -2,28 +2,17 @@ package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalAccountViewModel
|
||||
import su.reya.coop.LocalAppViewModel
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileViewModel
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UpdateProfileScreen() {
|
||||
val appViewModel = LocalAppViewModel.current
|
||||
val accountViewModel = LocalAccountViewModel.current
|
||||
val profileViewModel = LocalProfileViewModel.current
|
||||
fun UpdateProfileScreen(viewModel: AccountViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||
val profile = currentUser?.metadata?.asRecord()
|
||||
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Update profile",
|
||||
@@ -31,13 +20,11 @@ fun UpdateProfileScreen() {
|
||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||
initialBio = profile?.about ?: "",
|
||||
initialPicture = profile?.picture,
|
||||
isBusy = isBusy,
|
||||
isBusy = isUpdatingProfile,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
profileViewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.humanReadable
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodel.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
|
||||
val pubkey = room.members.firstOrNull() ?: return
|
||||
|
||||
val profileCache = LocalProfileCache.current
|
||||
|
||||
var isContact by remember { mutableStateOf(false) }
|
||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||
|
||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(pubkey) {
|
||||
// Check contact
|
||||
viewModel.verifyContact(pubkey) { isContact = it }
|
||||
// Get mutual contacts
|
||||
viewModel.mutualContacts(pubkey) { mutualContacts = it }
|
||||
// Get the last activity
|
||||
viewModel.verifyActivity(pubkey) { lastActivity = it }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
picture = profile?.picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.size(120.dp),
|
||||
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = profile?.name ?: "No name",
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontFamily = getExpressiveFontFamily()
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = pubkey.short(),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (isContact) "Contact" else "Not a contact",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||
),
|
||||
contentDescription = "Warning",
|
||||
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_check_circle),
|
||||
contentDescription = "Warning",
|
||||
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_add_circle
|
||||
import coop.composeapp.generated.resources.ic_audio
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onMicClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
maxLines = 5,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Default
|
||||
),
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onUpload) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_add_circle),
|
||||
contentDescription = "Upload",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
AnimatedContent(
|
||||
targetState = value.isNotEmpty(),
|
||||
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||
label = "send_mic_transition"
|
||||
) { isNotEmpty ->
|
||||
if (isNotEmpty) {
|
||||
IconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledTonalIconButton(
|
||||
onClick = onMicClick,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_audio),
|
||||
contentDescription = "Speech to Text"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
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.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.URL_REGEX
|
||||
import su.reya.coop.formatAsTime
|
||||
import su.reya.coop.isImageUrl
|
||||
import su.reya.coop.removeImageUrls
|
||||
|
||||
@Immutable
|
||||
data class MessageModel(
|
||||
val id: EventId,
|
||||
val author: PublicKey,
|
||||
val annotatedContent: AnnotatedString,
|
||||
val images: List<String>,
|
||||
val timestamp: String,
|
||||
val isMine: Boolean,
|
||||
val replyEventIds: List<EventId>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||
return remember(event, currentUser) {
|
||||
val id = event.ensureId().id()!!
|
||||
val isMine = currentUser == event.author()
|
||||
val content = event.content()
|
||||
val replyEventIds = event.tags().eventIds()
|
||||
|
||||
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||
val cleanedContent = content.removeImageUrls()
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||
val url = matchResult.value
|
||||
pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = url,
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
append(url)
|
||||
pop()
|
||||
lastIndex = matchResult.range.last + 1
|
||||
}
|
||||
append(cleanedContent.substring(lastIndex))
|
||||
}
|
||||
|
||||
MessageModel(
|
||||
id = id,
|
||||
author = event.author(),
|
||||
annotatedContent = annotatedString,
|
||||
images = images,
|
||||
timestamp = event.createdAt().formatAsTime(),
|
||||
isMine = isMine,
|
||||
replyEventIds = replyEventIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
model: MessageModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onLongClick: (Rect) -> Unit = {}
|
||||
) {
|
||||
var isMessageClicked by remember { mutableStateOf(false) }
|
||||
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
val bubbleShape = if (model.isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val containerColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||
|
||||
val contentColor =
|
||||
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned { layoutCoordinates = it }
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { isMessageClicked = !isMessageClicked },
|
||||
onLongClick = {
|
||||
layoutCoordinates?.let { coords ->
|
||||
onLongClick(coords.boundsInWindow())
|
||||
}
|
||||
}
|
||||
),
|
||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (model.annotatedContent.isNotBlank()) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
) {
|
||||
Text(
|
||||
text = model.annotatedContent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
model.images.forEach { imageUrl ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Image from chat",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.align(
|
||||
if (model.isMine) Alignment.End else Alignment.Start
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
package su.reya.coop.screens.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.speech.RecognizerIntent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.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.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_copy
|
||||
import coop.composeapp.generated.resources.ic_reply
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomUiState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.formatAsGroup
|
||||
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)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatScreenViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val id = viewModel.id
|
||||
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val loading = viewModel.loading
|
||||
val newOtherMessages = viewModel.newOtherMessages
|
||||
val requireScreening = viewModel.requireScreening
|
||||
val messages = viewModel.messages
|
||||
|
||||
val groupedMessages =
|
||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||
|
||||
val roomState by remember(id, currentUser?.publicKey) {
|
||||
(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 ->
|
||||
scope.launch {
|
||||
// Read file on IO dispatcher
|
||||
val file = withContext(coroutineDispatcher) {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}
|
||||
|
||||
// Parse the file content type
|
||||
val type = context.contentResolver.getType(uri)
|
||||
|
||||
// Send message (handles errors internally via ViewModel)
|
||||
viewModel.sendFileMessage(file, type)
|
||||
}
|
||||
}
|
||||
|
||||
val fileLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { sendFile(it) }
|
||||
}
|
||||
|
||||
val sttLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
if (!results.isNullOrEmpty()) text = results[0]
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.blur(blurAmount),
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room?.members?.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (requireScreening) {
|
||||
room?.let { ScreenerCard(accountViewModel, it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
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)
|
||||
|
||||
val replyPreview =
|
||||
remember(model.replyEventIds, messages.size) {
|
||||
model.replyEventIds.firstOrNull()
|
||||
?.let { replyId ->
|
||||
messages.find { it.id() == replyId }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
replyPreview?.let { ReplyPreview(it, model.isMine) }
|
||||
ChatMessage(
|
||||
model = model,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha =
|
||||
if (selectedMessage?.first?.id == model.id) 0f else 1f
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening) {
|
||||
true -> {
|
||||
Row(
|
||||
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(
|
||||
onClick = { viewModel.requireScreening = false },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.size(ButtonDefaults.MediumContainerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "Accept",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
AnimatedVisibility(visible = replyingTo != null) {
|
||||
replyingTo?.let {
|
||||
ReplyBox(it) { replyingTo = null }
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(text)
|
||||
text = ""
|
||||
},
|
||||
onUpload = {
|
||||
fileLauncher.launch("image/*")
|
||||
},
|
||||
onMicClick = {
|
||||
val intent =
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -53,7 +54,7 @@ import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -70,11 +71,14 @@ fun ProfileEditor(
|
||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||
isBusy: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
|
||||
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||
@@ -264,16 +268,19 @@ fun ProfileEditor(
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.MediumContainerHeight),
|
||||
onClick = {
|
||||
val scope = CoroutineScope(Dispatchers.Main)
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
(picture as? Uri)?.let {
|
||||
context.contentResolver.openInputStream(it)?.readBytes()
|
||||
try {
|
||||
val bytes = withContext(ioDispatcher) {
|
||||
(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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.nostr.Room
|
||||
import su.reya.coop.short
|
||||
import su.reya.coop.viewmodels.ProfileViewModel
|
||||
|
||||
fun Room.nameFlow(
|
||||
profileViewModel: ProfileViewModel,
|
||||
currentUser: PublicKey? = null
|
||||
): Flow<String> {
|
||||
// Return early if there's a custom subject/room name
|
||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||
|
||||
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
val profile = metadata?.asRecord()
|
||||
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: profile?.name?.takeIf { it.isNotBlank() }
|
||||
?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
if (isGroup()) {
|
||||
val combined = names.joinToString(", ")
|
||||
val extraCount = members.size - names.size
|
||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||
} else {
|
||||
val name = names.first()
|
||||
if (displayMembers.first() == currentUser) "$name (you)" else name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-testExt = "1.3.0"
|
||||
androidx-splashscreen = "1.2.0"
|
||||
composeMultiplatform = "1.11.0"
|
||||
composeMultiplatform = "1.11.1"
|
||||
datastorePreferences = "1.2.1"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.21"
|
||||
kotlin = "2.4.0"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
material3 = "1.11.0-alpha07"
|
||||
multiplatform-nav3-ui = "1.1.1"
|
||||
ktor = "3.5.0"
|
||||
ktor = "3.5.1"
|
||||
|
||||
[libraries]
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||
|
||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||
}
|
||||
|
||||
@@ -31,10 +33,12 @@ kotlin {
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
implementation("com.squareup.okio:okio:3.17.0")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal file
14
shared/src/commonMain/kotlin/su/reya/coop/AppStorage.kt
Normal 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
|
||||
}
|
||||
@@ -2,6 +2,22 @@ package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.PublicKey
|
||||
|
||||
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
|
||||
|
||||
fun String.removeImageUrls(): String {
|
||||
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
|
||||
}
|
||||
|
||||
fun String.isImageUrl(): Boolean {
|
||||
val extension = this.substringAfterLast('.', "").lowercase()
|
||||
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)
|
||||
|
||||
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.PublicKey
|
||||
|
||||
data class Profile(
|
||||
val publicKey: PublicKey,
|
||||
val metadata: Metadata
|
||||
) {
|
||||
private val record by lazy { metadata.asRecord() }
|
||||
|
||||
val name: String
|
||||
get() = record.displayName?.sanitizeName() ?: record.name ?: publicKey.short()
|
||||
|
||||
val picture: String?
|
||||
get() = record.picture
|
||||
|
||||
val shortPublicKey: String
|
||||
get() = publicKey.short()
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package su.reya.coop.nostr
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
@@ -8,6 +11,7 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -33,8 +37,7 @@ data class Room(
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||
val id = rumor.roomId()
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||
val createdAt = rumor.createdAt()
|
||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||
|
||||
@@ -59,24 +62,48 @@ data class Room(
|
||||
}
|
||||
}
|
||||
|
||||
fun setKind(kind: RoomKind): Room {
|
||||
return this.copy(kind = kind)
|
||||
fun isGroup(): Boolean = members.size > 1
|
||||
}
|
||||
|
||||
data class RoomUiState(
|
||||
val name: String = "Loading...",
|
||||
val picture: String? = null,
|
||||
val isGroup: Boolean = false
|
||||
)
|
||||
|
||||
fun Room.uiStateFlow(
|
||||
profileCache: ProfileCache,
|
||||
currentUser: PublicKey? = null
|
||||
): Flow<RoomUiState> {
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
|
||||
if (!subject.isNullOrBlank()) {
|
||||
return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
|
||||
}
|
||||
|
||||
fun setCreatedAt(createdAt: Timestamp): Room {
|
||||
return this.copy(createdAt = createdAt)
|
||||
}
|
||||
return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
|
||||
val names = profiles.mapIndexed { i, profile ->
|
||||
profile?.name?.sanitizeName() ?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
fun setSubject(subject: String?): Room {
|
||||
return this.copy(subject = subject)
|
||||
}
|
||||
val name = when {
|
||||
isGroup() -> {
|
||||
val combined = names.joinToString(", ")
|
||||
val extra = members.size - names.size
|
||||
if (extra > 0) "$combined, +$extra" else combined
|
||||
}
|
||||
|
||||
fun setLastMessage(message: String?): Room {
|
||||
return this.copy(lastMessage = message)
|
||||
}
|
||||
else -> {
|
||||
val first = names.firstOrNull() ?: "Unknown"
|
||||
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||
}
|
||||
}
|
||||
|
||||
fun isGroup(): Boolean {
|
||||
return members.size > 1
|
||||
RoomUiState(
|
||||
name = name,
|
||||
picture = profiles.firstOrNull()?.picture,
|
||||
isGroup = isGroup()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,29 +113,28 @@ fun UnsignedEvent.roomId(): Long {
|
||||
pubkeys.add(this.author())
|
||||
pubkeys.addAll(this.tags().publicKeys())
|
||||
|
||||
// Sort and hash the list of public keys
|
||||
val sortedUniqueKeys = pubkeys
|
||||
.distinctBy { it.toBech32() }
|
||||
.sortedBy { it.toBech32() }
|
||||
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
|
||||
}
|
||||
|
||||
return sortedUniqueKeys.hashCode().toLong()
|
||||
fun Timestamp.formatAsTime(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
|
||||
val hour = inputDateTime.hour.toString().padStart(2, '0')
|
||||
val minute = inputDateTime.minute.toString().padStart(2, '0')
|
||||
return "$hour:$minute"
|
||||
}
|
||||
|
||||
fun Timestamp.ago(): String {
|
||||
val SECONDS_IN_MINUTE = 60L
|
||||
val MINUTES_IN_HOUR = 60L
|
||||
val HOURS_IN_DAY = 24L
|
||||
val DAYS_IN_MONTH = 30L
|
||||
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val now = Clock.System.now()
|
||||
val duration = now - inputInstant
|
||||
|
||||
return when {
|
||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
||||
duration.inWholeSeconds < 60L -> "Now"
|
||||
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
|
||||
else -> {
|
||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val month =
|
||||
@@ -119,7 +145,7 @@ fun Timestamp.ago(): String {
|
||||
}
|
||||
}
|
||||
|
||||
fun Timestamp.formatAsGroupHeader(): String {
|
||||
fun Timestamp.formatAsGroup(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
||||
22
shared/src/commonMain/kotlin/su/reya/coop/Settings.kt
Normal file
22
shared/src/commonMain/kotlin/su/reya/coop/Settings.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Settings(
|
||||
val theme: Theme = Theme.System,
|
||||
val dynamicColor: Boolean = true,
|
||||
val media: Media = Media.AlwaysEnabled,
|
||||
val screening: Boolean = true,
|
||||
val blossomServer: String? = "https://blossom.band",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Theme {
|
||||
Light, Dark, System
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class Media {
|
||||
Disabled, DisabledForMobileData, AlwaysEnabled
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -24,6 +25,9 @@ import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.roomId
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class MessageSyncState(
|
||||
@@ -35,19 +39,20 @@ class MessageManager(private val nostr: Nostr) {
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
|
||||
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
|
||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||
val messageSyncState = _messageSyncState.asStateFlow()
|
||||
|
||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
|
||||
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||
_messageSyncState.update(update)
|
||||
}
|
||||
|
||||
suspend fun getUserMessages(msgRelayList: Event) {
|
||||
try {
|
||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
val author =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val relays = nip17ExtractRelayList(msgRelayList)
|
||||
|
||||
// Ensure relay connections
|
||||
@@ -67,6 +72,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
target = ReqTarget.manual(target),
|
||||
id = "gift-wraps"
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
}
|
||||
@@ -114,7 +121,9 @@ class MessageManager(private val nostr: Nostr) {
|
||||
setCachedRumor(event.id(), unsignedEvent)
|
||||
|
||||
return unsignedEvent
|
||||
} catch (e: Throwable) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||
return null
|
||||
}
|
||||
@@ -125,8 +134,10 @@ class MessageManager(private val nostr: Nostr) {
|
||||
val filter = Filter().identifier(giftId.toHex())
|
||||
val event = client?.database()?.query(filter)?.first()
|
||||
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||
} catch (e: Throwable) {
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +161,9 @@ class MessageManager(private val nostr: Nostr) {
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
client?.database()?.saveEvent(event)
|
||||
} catch (e: Throwable) {
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to set cached rumor: ${e.message}")
|
||||
}
|
||||
}
|
||||
@@ -165,36 +178,35 @@ class MessageManager(private val nostr: Nostr) {
|
||||
|
||||
// Get all DM events
|
||||
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
|
||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||
|
||||
events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.forEach { event ->
|
||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||
val existingRoom = roomsMap[newRoom.id]
|
||||
.map { UnsignedEvent.fromJson(it.content()) }
|
||||
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
.forEach { rumor ->
|
||||
val id = rumor.roomId()
|
||||
val isFromMe = rumor.author() == userPubkey
|
||||
val existing = roomsMap[id]
|
||||
val createdAt = rumor.createdAt()
|
||||
|
||||
// Check if the room already exists
|
||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||
.customTag(rTag, newRoom.id.toString())
|
||||
|
||||
// Determine if it's an ongoing room
|
||||
val isOngoing =
|
||||
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||
|
||||
// Append room to map
|
||||
roomsMap[newRoom.id] =
|
||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||
// If the room is new or the current rumor is newer than the existing one
|
||||
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
|
||||
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
|
||||
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||
}
|
||||
}
|
||||
|
||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get chat rooms: ${e.message}")
|
||||
return null
|
||||
@@ -214,6 +226,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
@@ -237,6 +251,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
connectMsgRelays(event)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||
}
|
||||
@@ -316,9 +332,6 @@ class MessageManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
if (output != null) {
|
||||
// Keep track of sent events
|
||||
sentEvents[output.id] = emptyList()
|
||||
|
||||
// Keep track of rumor IDs
|
||||
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||
rumorMap[id] = output.id
|
||||
@@ -329,6 +342,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.ClientBuilder
|
||||
@@ -30,24 +33,15 @@ import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.initLogger
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
object NostrManager {
|
||||
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
|
||||
private set
|
||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||
@@ -59,17 +53,18 @@ class Nostr {
|
||||
|
||||
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()
|
||||
|
||||
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||
_newEvents.emit(event)
|
||||
fun emitNewEvent(event: UnsignedEvent) {
|
||||
_newEvents.tryEmit(event)
|
||||
}
|
||||
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
logLevel: LogLevel = LogLevel.WARN
|
||||
) {
|
||||
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
|
||||
try {
|
||||
if (isInitialized.value) return
|
||||
|
||||
@@ -79,6 +74,8 @@ class Nostr {
|
||||
// Initialize configurations for nostr client
|
||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||
val gossip = NostrGossip.inMemory()
|
||||
|
||||
// Initialize the authenticator
|
||||
val authenticator = SignerAuthenticator(signer)
|
||||
val idleTimeout = Duration.parse("5m")
|
||||
|
||||
@@ -103,21 +100,14 @@ class Nostr {
|
||||
}
|
||||
|
||||
suspend fun waitUntilInitialized() {
|
||||
isInitialized.first { it }
|
||||
withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
|
||||
?: throw IllegalStateException("Nostr initialization timed out")
|
||||
}
|
||||
|
||||
suspend fun connectBootstrapRelays() {
|
||||
relays.connectBootstrapRelays()
|
||||
}
|
||||
|
||||
suspend fun reconnect() {
|
||||
relays.reconnect()
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
relays.disconnect()
|
||||
}
|
||||
|
||||
suspend fun prune() {
|
||||
try {
|
||||
client?.database()?.wipe()
|
||||
@@ -136,7 +126,7 @@ class Nostr {
|
||||
|
||||
fun isSignedByUser(event: Event): Boolean {
|
||||
return try {
|
||||
signer.currentUser == event.author()
|
||||
signer.publicKeyFlow.value == event.author()
|
||||
} catch (e: Exception) {
|
||||
println("Failed to check if event is signed by user: ${e.message}")
|
||||
false
|
||||
@@ -153,11 +143,11 @@ class Nostr {
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
val notifications = client?.notifications() ?: return@supervisorScope
|
||||
|
||||
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||
val giftWrapQueue = Channel<Event>(1024)
|
||||
var processedCount = 0
|
||||
var eoseReceived = false
|
||||
|
||||
launch(Dispatchers.Default) {
|
||||
launch(defaultDispatcher) {
|
||||
for (event in giftWrapQueue) {
|
||||
val rumor = messages.extractRumor(event)
|
||||
processedCount++
|
||||
@@ -184,8 +174,6 @@ class Nostr {
|
||||
|
||||
when (notification) {
|
||||
is ClientNotification.Message -> {
|
||||
val relayUrl = notification.relayUrl
|
||||
|
||||
when (val message = notification.message.asEnum()) {
|
||||
is RelayMessageEnum.EventMsg -> {
|
||||
val event = message.event
|
||||
@@ -208,7 +196,7 @@ class Nostr {
|
||||
if (isSignedByUser(event = event)) {
|
||||
val pubkeys = event.tags().publicKeys()
|
||||
// Get mutual contacts
|
||||
profiles.getMutualContacts(pubkeys)
|
||||
profiles.syncMutualContacts(pubkeys)
|
||||
// Emit contact list update
|
||||
onContactListUpdate(pubkeys)
|
||||
}
|
||||
@@ -217,6 +205,7 @@ class Nostr {
|
||||
KindStandard.INBOX_RELAYS -> {
|
||||
// Get all gift wrap events for the current user
|
||||
if (isSignedByUser(event = event)) {
|
||||
messages.updateSyncState { it.copy(isSyncing = true) }
|
||||
messages.getUserMessages(msgRelayList = event)
|
||||
}
|
||||
}
|
||||
@@ -238,14 +227,6 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
is RelayMessageEnum.Ok -> {
|
||||
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||
val currentRelays =
|
||||
messages.sentEvents[message.eventId] ?: emptyList()
|
||||
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
/* Ignore other message types */
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
@@ -26,17 +27,28 @@ import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
|
||||
class ProfileManager(private val nostr: Nostr) {
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
private val httpClient by lazy { HttpClient() }
|
||||
|
||||
private val _metadataUpdates =
|
||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 100,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND
|
||||
)
|
||||
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()
|
||||
|
||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||
@@ -49,7 +61,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
|
||||
suspend fun getUserMetadata() {
|
||||
try {
|
||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
val author =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// Get the latest metadata event
|
||||
val metadataFilter =
|
||||
@@ -68,29 +81,24 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
||||
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||
try {
|
||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||
}
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.manual(target),
|
||||
id = "mutual-contacts",
|
||||
closeOn = opts,
|
||||
)
|
||||
client?.sync(filter, relays)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||
println("Failed to sync mutual contacts: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,18 +154,24 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
bio: String? = null,
|
||||
picture: String? = null
|
||||
): Metadata {
|
||||
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
try {
|
||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||
val newRecord = record.copy(
|
||||
displayName = name ?: record.displayName,
|
||||
about = bio ?: record.about,
|
||||
picture = picture ?: record.picture
|
||||
)
|
||||
val newMetadata = Metadata.fromRecord(newRecord)
|
||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||
val currentUser =
|
||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// 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(
|
||||
event = event,
|
||||
target = SendEventTarget.broadcast(),
|
||||
@@ -165,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
return newMetadata
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||
}
|
||||
@@ -177,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||
|
||||
Metadata.fromJson(event.content())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get latest metadata: ${e.message}")
|
||||
null
|
||||
@@ -199,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get all cache metadata: ${e.message}")
|
||||
return emptyMap()
|
||||
@@ -218,11 +238,13 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
|
||||
// Construct request target
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||
}
|
||||
|
||||
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||
}
|
||||
@@ -238,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||
}
|
||||
@@ -249,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val bodyString: String = response.body()
|
||||
|
||||
return Nip05Profile.fromJson(address, bodyString)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||
}
|
||||
@@ -257,9 +283,11 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
suspend fun searchByAddress(query: String): PublicKey {
|
||||
try {
|
||||
val address = Nip05Address.parse(query)
|
||||
val profile = profileFromAddress(HttpClient(), address)
|
||||
val profile = profileFromAddress(httpClient, address)
|
||||
|
||||
return profile.publicKey()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||
}
|
||||
@@ -295,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||
}
|
||||
@@ -304,7 +334,7 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
try {
|
||||
val filter = Filter().author(pubkey).limit(3u)
|
||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||
}
|
||||
|
||||
@@ -314,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
return events?.first()?.createdAt()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||
}
|
||||
@@ -331,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||
|
||||
return pubkeys.contains(pubkey)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
@@ -352,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return contacts.toSet()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import kotlin.time.Duration
|
||||
|
||||
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 signer: UniversalSigner get() = nostr.signer
|
||||
|
||||
suspend fun connectBootstrapRelays() {
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||
BOOTSTRAP_RELAYS.forEach { url ->
|
||||
client?.addRelay(RelayUrl.parse(url))
|
||||
}
|
||||
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||
INDEXER_RELAY.forEach { url ->
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse(url),
|
||||
capabilities = RelayCapabilities.gossip()
|
||||
@@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
suspend fun reconnect() {
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||
if (relay != null) {
|
||||
@@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.disconnectRelay(RelayUrl.parse(url))
|
||||
} catch (e: Exception) {
|
||||
@@ -104,6 +118,32 @@ class RelayManager(private val nostr: Nostr) {
|
||||
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>) {
|
||||
try {
|
||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||
@@ -134,8 +174,9 @@ class RelayManager(private val nostr: Nostr) {
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||
val events = client?.database()?.query(filter)
|
||||
val event = events?.first() ?: return emptyList()
|
||||
|
||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||
return nip17ExtractRelayList(event)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||
}
|
||||
@@ -153,30 +194,4 @@ class RelayManager(private val nostr: Nostr) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package su.reya.coop.nostr
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
@Volatile
|
||||
private var signer: AsyncNostrSigner = initialSigner
|
||||
|
||||
@Volatile
|
||||
var currentUser: PublicKey? = null
|
||||
private set
|
||||
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||
|
||||
/**
|
||||
* Get the current signer.
|
||||
@@ -28,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
/**
|
||||
* Switch to a new signer.
|
||||
*/
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||
val pubkey = try {
|
||||
withTimeoutOrNull(20.seconds) {
|
||||
newSigner.getPublicKeyAsync()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get public key from signer", e)
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) {
|
||||
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
|
||||
?: throw IllegalStateException("Failed to get public key from signer")
|
||||
|
||||
mutex.withLock {
|
||||
signer = newSigner
|
||||
_publicKeyFlow.value = pubkey
|
||||
}
|
||||
signer = newSigner
|
||||
currentUser = pubkey
|
||||
}
|
||||
|
||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
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 isRelayListEmpty: Boolean = false,
|
||||
val contactList: Set<PublicKey> = emptySet(),
|
||||
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
|
||||
val userMsgRelayList: List<RelayUrl> = emptyList(),
|
||||
)
|
||||
|
||||
class AccountRepository(
|
||||
private val nostr: Nostr,
|
||||
private val storage: AppStorage,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
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 { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
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
|
||||
_state.update { it.copy(isRelayListEmpty = 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) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectExternalSigner() {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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 ->
|
||||
_state.update { it.copy(contactList = contacts.toSet()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = 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 _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _state.value.contactList + pubkey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_state.update { it.copy(contactList = it.contactList + pubkey) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContact(publicKey: PublicKey) {
|
||||
scope.launch {
|
||||
if (publicKey !in _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _state.value.contactList - publicKey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_state.update { it.copy(contactList = it.contactList - 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() {
|
||||
_state.update { it.copy(isRelayListEmpty = 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")
|
||||
val relayList = nostr.relays.getRelayList(user)
|
||||
_state.update { it.copy(userRelayList = relayList) }
|
||||
} 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")
|
||||
val msgRelays = nostr.relays.getMsgRelays(user)
|
||||
_state.update { it.copy(userMsgRelayList = msgRelays) }
|
||||
} 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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.asStateFlow
|
||||
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.RoomKind
|
||||
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 settingsRepository: SettingsRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
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 ->
|
||||
updateRoomState(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 dbRooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
_state.update { currentState ->
|
||||
val newMap = currentState.rooms.toMutableMap()
|
||||
dbRooms.forEach { dbRoom ->
|
||||
val existing = newMap[dbRoom.id]
|
||||
// Only update if the database version is newer or equal
|
||||
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
|
||||
// Preserve Ongoing kind if already marked as such in memory
|
||||
val mergedKind =
|
||||
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
|
||||
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind)
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
scope.launch(defaultDispatcher) {
|
||||
updateRoomState(it, roomId)
|
||||
}
|
||||
},
|
||||
)
|
||||
} 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||
|
||||
_state.update { currentState ->
|
||||
val rooms = currentState.rooms.toMutableMap()
|
||||
val existingRoom = rooms[roomId]
|
||||
|
||||
val isFromMe = event.author() == currentUser
|
||||
val newKind =
|
||||
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
|
||||
|
||||
if (existingRoom == null) {
|
||||
// New room discovery
|
||||
val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
|
||||
rooms[newRoom.id] = newRoom
|
||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
||||
// Only update preview if message is newer (handles sync/late arrivals)
|
||||
rooms[roomId] = existingRoom.copy(
|
||||
lastMessage = event.content(),
|
||||
createdAt = event.createdAt(),
|
||||
kind = newKind
|
||||
)
|
||||
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
|
||||
// Even if it's an older message, if it's from me, the room is ongoing
|
||||
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
|
||||
} else {
|
||||
// Don't update the room list state for older messages
|
||||
return@update currentState
|
||||
}
|
||||
currentState.copy(rooms = rooms)
|
||||
}
|
||||
|
||||
// Notify subscribers about the new event (for the active chat screen)
|
||||
_newEvents.tryEmit(event)
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
rooms = emptyMap(),
|
||||
isPartialProcessedGiftWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
|
||||
class MediaRepository(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun blossomUpload(
|
||||
signer: AsyncNostrSigner,
|
||||
file: ByteArray,
|
||||
contentType: String? = "image/jpeg"
|
||||
): String? {
|
||||
return try {
|
||||
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
||||
val blossom = BlossomClient(url, httpClient)
|
||||
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
||||
descriptor?.url
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Upload failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package su.reya.coop.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import su.reya.coop.AppStorage
|
||||
import su.reya.coop.Settings
|
||||
|
||||
class SettingsRepository(
|
||||
private val storage: AppStorage,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
companion object {
|
||||
private const val KEY_SETTINGS = "app_settings"
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _settings = MutableStateFlow(Settings())
|
||||
val settings: StateFlow<Settings> = _settings.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
_settings.value = load()
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (Settings) -> Settings) {
|
||||
scope.launch {
|
||||
val newSettings = transform(_settings.value)
|
||||
_settings.value = newSettings
|
||||
save(newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun save(settings: Settings) {
|
||||
val jsonString = json.encodeToString(settings)
|
||||
storage.set(KEY_SETTINGS, jsonString)
|
||||
}
|
||||
|
||||
private suspend fun load(): Settings {
|
||||
val jsonString = storage.get(KEY_SETTINGS)
|
||||
return if (jsonString != null) {
|
||||
try {
|
||||
json.decodeFromString<Settings>(jsonString)
|
||||
} catch (_: Exception) {
|
||||
Settings()
|
||||
}
|
||||
} else {
|
||||
Settings()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import rust.nostr.sdk.PublicKey
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.ChatState
|
||||
|
||||
class ChatViewModel(
|
||||
private val repository: ChatRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
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
|
||||
|
||||
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
|
||||
fun refreshChatRooms() = repository.refreshChatRooms()
|
||||
fun resetInternalState() = repository.resetInternalState()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import su.reya.coop.Settings
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
|
||||
class SettingsViewModel(
|
||||
private val repository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
val settings: StateFlow<Settings> = repository.settings
|
||||
|
||||
fun update(transform: (Settings) -> Settings) {
|
||||
repository.update(transform)
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.storage.SecretStorage
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class AccountViewModel(
|
||||
val nostr: Nostr,
|
||||
private val secretStore: SecretStorage,
|
||||
private val appViewModel: AppViewModel,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : ViewModel() {
|
||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||
val signerRequired = _signerRequired.asStateFlow()
|
||||
|
||||
init {
|
||||
// Skip the splash screen if a user is already logged in
|
||||
if (nostr.signer.currentUser != null) {
|
||||
_signerRequired.value = false
|
||||
}
|
||||
// Check local stored secret
|
||||
login()
|
||||
}
|
||||
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = withTimeoutOrNull(3.seconds) {
|
||||
secretStore.get("user_signer")
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_signerRequired.value = true
|
||||
return@launch
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val signer = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
}.onSuccess {
|
||||
_signerRequired.value = false
|
||||
}.onFailure { e ->
|
||||
appViewModel.showError("Login failed: ${e.message}")
|
||||
_signerRequired.value = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Login failed: ${e.message}")
|
||||
_signerRequired.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
appViewModel.setBusy(true)
|
||||
// Reset the nostr signer and prune the database
|
||||
nostr.signer.switch(Keys.generate())
|
||||
nostr.prune()
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Logout encountered an error: ${e.message}")
|
||||
} finally {
|
||||
// Clear credentials from persistent storage
|
||||
secretStore.clear("user_signer")
|
||||
secretStore.clear("notification_banner_dismissed")
|
||||
// Reset local states
|
||||
appViewModel.setBusy(false)
|
||||
_signerRequired.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getOrInitAppKeys(): Keys {
|
||||
val secret = secretStore.get("app_keys")
|
||||
if (secret != null) return Keys.parse(secret)
|
||||
|
||||
val keys = Keys.generate()
|
||||
secretStore.set("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")
|
||||
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://")) {
|
||||
appViewModel.showError("Please approve the connection.")
|
||||
}
|
||||
return signer.getPublicKeyAsync()
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importIdentity(secret: String) {
|
||||
appViewModel.setBusy(true)
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
nostr.setSigner(signer)
|
||||
secretStore.set("user_signer", secret)
|
||||
_signerRequired.value = false
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
} finally {
|
||||
appViewModel.setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = "image/jpeg",
|
||||
profileViewModel: ProfileViewModel
|
||||
) {
|
||||
appViewModel.setBusy(true)
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { profileViewModel.blossomUpload(it, contentType) }
|
||||
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||
// Set credentials in persistent storage
|
||||
secretStore.set("user_signer", secret)
|
||||
// Update local state
|
||||
_signerRequired.value = false
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
} finally {
|
||||
appViewModel.setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectExternalSigner() {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
appViewModel.setBusy(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)
|
||||
nostr.setSigner(signer)
|
||||
// Set credentials in persistent storage
|
||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||
// Update local state
|
||||
_signerRequired.value = false
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Notice: ${e.message}")
|
||||
} finally {
|
||||
appViewModel.setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
|
||||
class AppViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage
|
||||
) : ViewModel() {
|
||||
private val _isBusy = MutableStateFlow(false)
|
||||
val isBusy = _isBusy.asStateFlow()
|
||||
|
||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
init {
|
||||
checkNotificationBannerDismissedStatus()
|
||||
}
|
||||
|
||||
fun setBusy(busy: Boolean) {
|
||||
_isBusy.value = busy
|
||||
}
|
||||
|
||||
fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
_isNotificationBannerDismissed.value =
|
||||
secretStore.get("notification_banner_dismissed") == "true"
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set("notification_banner_dismissed", "true")
|
||||
_isNotificationBannerDismissed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.nostr.Nostr
|
||||
import su.reya.coop.nostr.Room
|
||||
import su.reya.coop.nostr.roomId
|
||||
|
||||
class ChatViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val appViewModel: AppViewModel
|
||||
) : ViewModel() {
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||
|
||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
suspend fun bindObservers() = coroutineScope {
|
||||
launch {
|
||||
nostr.messages.messageSyncState.collect { state ->
|
||||
if (state.processedCount > 0) {
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||
refreshChatRooms()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.currentUser
|
||||
if (currentUser != null) {
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||
}
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatRoom(to: List<PublicKey>): Long {
|
||||
try {
|
||||
val currentUser =
|
||||
nostr.signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||
.tags(to.map { Tag.publicKey(it) })
|
||||
.finalizeUnsigned(currentUser)
|
||||
|
||||
val id = rumor.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
||||
|
||||
if (existingRoom != null) {
|
||||
return existingRoom.id
|
||||
}
|
||||
|
||||
val room = Room.new(rumor, currentUser)
|
||||
_chatRooms.update { currentRooms ->
|
||||
(currentRooms + room).sortedDescending().toSet()
|
||||
}
|
||||
|
||||
return room.id
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun getChatRooms() {
|
||||
viewModelScope.launch {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeChatRooms(rooms: Set<Room>) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
merged.values.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||
try {
|
||||
return nostr.messages.getChatRoomMessages(roomId)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.messages.chatRoomConnect(room.members.toList())
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
if (message.isEmpty()) {
|
||||
appViewModel.showError("Message cannot be empty")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isMessageSent(id: EventId): Boolean {
|
||||
val giftWrapId = nostr.messages.rumorMap[id]
|
||||
return if (giftWrapId != null) {
|
||||
nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
currentRooms.map { room ->
|
||||
if (room.id == roomId) {
|
||||
room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
} else {
|
||||
room
|
||||
}
|
||||
}.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
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 kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ProfileViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val appViewModel: AppViewModel
|
||||
) : ViewModel() {
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
init {
|
||||
getCacheMetadata()
|
||||
}
|
||||
|
||||
suspend fun bindObservers() = coroutineScope {
|
||||
launch {
|
||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||
_contactList.value = contacts.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
|
||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
||||
}
|
||||
|
||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||
if (flow.value == null) {
|
||||
requestMetadata(pubkey)
|
||||
}
|
||||
return flow.asStateFlow()
|
||||
}
|
||||
|
||||
private fun requestMetadata(pubkey: PublicKey) {
|
||||
if (seenPublicKeys.add(pubkey)) {
|
||||
viewModelScope.launch {
|
||||
metadataRequestChannel.send(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCacheMetadata() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
val results = nostr.profiles.getAllCacheMetadata()
|
||||
results.forEach { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
seenPublicKeys.add(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
nostr.waitUntilInitialized()
|
||||
val batch = mutableSetOf<PublicKey>()
|
||||
val timeout = 500L
|
||||
|
||||
while (true) {
|
||||
val firstKey = metadataRequestChannel.receive()
|
||||
batch.add(firstKey)
|
||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
while (batch.isNotEmpty()) {
|
||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||
metadataRequestChannel.receive()
|
||||
}
|
||||
if (nextKey != null) batch.add(nextKey)
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun blossomUpload(file: ByteArray, contentType: String?): String? {
|
||||
try {
|
||||
val blossom = BlossomClient(
|
||||
url = "https://blossom.band",
|
||||
client = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = nostr.signer.get()
|
||||
)
|
||||
|
||||
return descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) {
|
||||
appViewModel.setBusy(true)
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||
updateMetadata(currentUser, newMetadata)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
} finally {
|
||||
appViewModel.setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addContact(address: String): Boolean {
|
||||
val pubkey = try {
|
||||
if (address.contains("@")) {
|
||||
nostr.profiles.searchByAddress(address)
|
||||
} else {
|
||||
PublicKey.parse(address)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Invalid contact address: ${e.message}")
|
||||
return false
|
||||
}
|
||||
|
||||
if (pubkey in contactList.value) return true
|
||||
|
||||
return try {
|
||||
val updated = contactList.value + pubkey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_contactList.update { it + pubkey }
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun removeContact(publicKey: PublicKey) {
|
||||
viewModelScope.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) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchByAddress(query: String): PublicKey? {
|
||||
try {
|
||||
return nostr.profiles.searchByAddress(query)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||
try {
|
||||
return nostr.profiles.searchByNostr(query)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||
return try {
|
||||
nostr.profiles.verifyActivity(pubkey)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||
return try {
|
||||
nostr.profiles.verifyContact(pubkey)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||
return try {
|
||||
nostr.profiles.mutualContacts(pubkey)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
setOf()
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserMetadata() {
|
||||
viewModelScope.launch {
|
||||
nostr.profiles.getUserMetadata()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package su.reya.coop.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class RelayViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val appViewModel: AppViewModel,
|
||||
private val chatViewModel: ChatViewModel,
|
||||
private val profileViewModel: ProfileViewModel
|
||||
) : ViewModel() {
|
||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||
|
||||
fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun observeSignerAndCheckRelays() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
val pubkey = nostr.signer.currentUser
|
||||
if (pubkey != null) {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
if (rooms.isNotEmpty()) {
|
||||
chatViewModel.mergeChatRooms(rooms)
|
||||
// Note: isPartialProcessedGiftWrap is in ChatViewModel
|
||||
// We might need to expose a way to set it or just let it be updated by observers
|
||||
}
|
||||
|
||||
profileViewModel.getUserMetadata()
|
||||
|
||||
delay(2.seconds)
|
||||
|
||||
val relays = nostr.relays.getMsgRelays(pubkey)
|
||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||
break
|
||||
}
|
||||
delay(500.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_isRelayListEmpty.value = false
|
||||
}
|
||||
|
||||
suspend fun useDefaultMsgRelayList() {
|
||||
try {
|
||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||
nostr.relays.setMsgRelays(defaultRelays)
|
||||
} catch (e: Exception) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.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) {
|
||||
appViewModel.showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user