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
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.artifacts/
|
||||||
@@ -25,8 +25,8 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
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-compose:3.5.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.2.2"
|
versionName = "0.2.5"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.App.Starting">
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<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.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -26,7 +25,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
@@ -34,7 +36,10 @@ import androidx.navigation3.runtime.entryProvider
|
|||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.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.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -47,30 +52,19 @@ import su.reya.coop.screens.RelayScreen
|
|||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.viewmodels.AccountViewModel
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodels.RelayViewModel
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
|
import su.reya.coop.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||||
error("No AppViewModel provided")
|
error("No ProfileCache provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||||
error("No AccountViewModel provided")
|
error("No Settings 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 LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
@@ -88,12 +82,39 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
appViewModel: AppViewModel,
|
profileCache: ProfileCache,
|
||||||
accountViewModel: AccountViewModel,
|
accountRepository: AccountRepository,
|
||||||
chatViewModel: ChatViewModel,
|
chatRepository: ChatRepository,
|
||||||
profileViewModel: ProfileViewModel,
|
settingsRepository: SettingsRepository,
|
||||||
relayViewModel: RelayViewModel,
|
|
||||||
) {
|
) {
|
||||||
|
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 context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
@@ -101,21 +122,27 @@ fun App(
|
|||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
// Get the signer required state
|
// 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
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Check if dark theme enabled
|
// 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
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
context
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// When dark mode is enabled, use the dark color scheme
|
// When dark mode is enabled, use the dark color scheme
|
||||||
darkMode -> darkColorScheme()
|
darkMode -> darkColorScheme()
|
||||||
@@ -123,22 +150,30 @@ fun App(
|
|||||||
else -> expressiveLightColorScheme()
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = backStack.size > 1) {
|
|
||||||
navigator.goBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
appViewModel.errorEvents.collect { message ->
|
launch {
|
||||||
|
accountViewModel.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
launch {
|
||||||
|
chatViewModel.errorEvents.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
profileCache.errorEvents.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(activity) {
|
LaunchedEffect(activity) {
|
||||||
activity?.let {
|
activity?.let {
|
||||||
fun handleIntent(intent: Intent) {
|
fun handleIntent(intent: Intent) {
|
||||||
val screen = Screen.fromIntent(intent)
|
val screen = Screen.fromIntent(intent)
|
||||||
// Prevent pushing the same screen
|
// Prevent pushing the same screen
|
||||||
if (screen != null && backStack.lastOrNull() != screen) {
|
if ((screen != null) && (backStack.lastOrNull() != screen)) {
|
||||||
navigator.navigate(screen)
|
navigator.navigate(screen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,11 +206,8 @@ fun App(
|
|||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalAppViewModel provides appViewModel,
|
LocalProfileCache provides profileCache,
|
||||||
LocalAccountViewModel provides accountViewModel,
|
LocalSettings provides settings,
|
||||||
LocalChatViewModel provides chatViewModel,
|
|
||||||
LocalProfileViewModel provides profileViewModel,
|
|
||||||
LocalRelayViewModel provides relayViewModel,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
@@ -195,43 +227,62 @@ fun App(
|
|||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen()
|
HomeScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.RequestList> {
|
entry<Screen.RequestList> {
|
||||||
RequestListScreen()
|
RequestListScreen(chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Onboarding> {
|
entry<Screen.Onboarding> {
|
||||||
OnboardingScreen()
|
OnboardingScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Import> {
|
entry<Screen.Import> {
|
||||||
ImportScreen()
|
ImportScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.NewIdentity> {
|
entry<Screen.NewIdentity> {
|
||||||
NewIdentityScreen()
|
NewIdentityScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Chat> { key ->
|
entry<Screen.Chat> { key ->
|
||||||
ChatScreen(id = key.id, screening = key.screening)
|
val factory = remember(key) {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return ChatScreenViewModel(
|
||||||
|
key.id,
|
||||||
|
key.screening,
|
||||||
|
accountRepository,
|
||||||
|
chatRepository
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatScreen(
|
||||||
|
viewModel<ChatScreenViewModel>(
|
||||||
|
key = key.id.toString(),
|
||||||
|
factory = factory
|
||||||
|
),
|
||||||
|
accountViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.NewChat> {
|
entry<Screen.NewChat> {
|
||||||
NewChatScreen()
|
NewChatScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Profile> { key ->
|
entry<Screen.Profile> { key ->
|
||||||
ProfileScreen(pubkey = key.pubkey)
|
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.UpdateProfile> {
|
entry<Screen.UpdateProfile> {
|
||||||
UpdateProfileScreen()
|
UpdateProfileScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Scan> {
|
entry<Screen.Scan> {
|
||||||
ScanScreen()
|
ScanScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.MyQr> {
|
entry<Screen.MyQr> {
|
||||||
MyQrScreen()
|
MyQrScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.ContactList> {
|
entry<Screen.ContactList> {
|
||||||
ContactListScreen()
|
ContactListScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen()
|
RelayScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package su.reya.coop.coop.storage
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
|
|||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
data class SecretEntry(
|
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||||
val encrypted: String,
|
|
||||||
val iv: String
|
data class SecretEntry(val encrypted: String, val iv: String)
|
||||||
)
|
|
||||||
|
|
||||||
class SecretCrypto {
|
class SecretCrypto {
|
||||||
private val keyAlias = "coop"
|
private val keyAlias = "coop"
|
||||||
@@ -21,11 +25,9 @@ class SecretCrypto {
|
|||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
fun encrypt(content: String): SecretEntry {
|
fun encrypt(content: String): SecretEntry {
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
|
||||||
// Encrypt content
|
|
||||||
val encrypted = cipher.doFinal(content.toByteArray())
|
val encrypted = cipher.doFinal(content.toByteArray())
|
||||||
val iv = cipher.iv
|
val iv = cipher.iv
|
||||||
|
|
||||||
@@ -39,12 +41,10 @@ class SecretCrypto {
|
|||||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||||
|
|
||||||
// Initialize cipher
|
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
val spec = GCMParameterSpec(128, iv)
|
val spec = GCMParameterSpec(128, iv)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||||
|
|
||||||
// Decrypt content
|
|
||||||
val plaintext = cipher.doFinal(encrypted)
|
val plaintext = cipher.doFinal(encrypted)
|
||||||
|
|
||||||
return String(plaintext, StandardCharsets.UTF_8)
|
return String(plaintext, StandardCharsets.UTF_8)
|
||||||
@@ -54,13 +54,9 @@ class SecretCrypto {
|
|||||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||||
val existingKey = keyStore.getKey(keyAlias, null)
|
val existingKey = keyStore.getKey(keyAlias, null)
|
||||||
|
|
||||||
// Return existing key if available
|
|
||||||
if (existingKey is SecretKey) return existingKey
|
if (existingKey is SecretKey) return existingKey
|
||||||
|
|
||||||
// Construct a new key generator
|
|
||||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||||
|
|
||||||
// Initialize key generation parameters
|
|
||||||
val spec = KeyGenParameterSpec.Builder(
|
val spec = KeyGenParameterSpec.Builder(
|
||||||
keyAlias,
|
keyAlias,
|
||||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
@@ -70,9 +66,49 @@ class SecretCrypto {
|
|||||||
.setKeySize(256)
|
.setKeySize(256)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Generate a new key
|
|
||||||
keyGenerator.init(spec)
|
keyGenerator.init(spec)
|
||||||
|
|
||||||
return keyGenerator.generateKey()
|
return keyGenerator.generateKey()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppStore(private val context: Context) : AppStorage {
|
||||||
|
private val crypto = SecretCrypto()
|
||||||
|
|
||||||
|
override suspend fun get(key: String): String? {
|
||||||
|
return context.dataStore.data.first()[stringPreferencesKey(key)]
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun set(key: String, value: String) {
|
||||||
|
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSecret(key: String): String? {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||||
|
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||||
|
|
||||||
|
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setSecret(key: String, value: String) {
|
||||||
|
val entry = crypto.encrypt(value)
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||||
|
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(key: String) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs.remove(stringPreferencesKey(key))
|
||||||
|
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||||
|
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun has(key: String): Boolean {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
return prefs.contains(stringPreferencesKey(key)) ||
|
||||||
|
prefs.contains(stringPreferencesKey("${key}_encrypted"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,15 @@ import android.content.Intent
|
|||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class ExternalSignerLauncher {
|
class ExternalSignerLauncher(
|
||||||
|
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
|
||||||
|
) {
|
||||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(mainDispatcher) {
|
||||||
val deferred = CompletableDeferred<ActivityResult>()
|
val deferred = CompletableDeferred<ActivityResult>()
|
||||||
pendingResult = deferred
|
pendingResult = deferred
|
||||||
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||||
|
|||||||
@@ -8,25 +8,14 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import kotlinx.coroutines.MainScope
|
||||||
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 su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.viewmodels.AccountViewModel
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.viewmodels.AppViewModel
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.viewmodels.ChatViewModel
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.viewmodels.ProfileViewModel
|
import su.reya.coop.repository.SettingsRepository
|
||||||
import su.reya.coop.viewmodels.RelayViewModel
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -34,36 +23,32 @@ class MainActivity : ComponentActivity() {
|
|||||||
val externalSignerLauncher = ExternalSignerLauncher()
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val factory by lazy {
|
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||||
object : ViewModelProvider.Factory {
|
private val scope = MainScope()
|
||||||
private val 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)
|
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
private val settingsRepository by lazy {
|
||||||
return when {
|
val storage = AppStore(this@MainActivity)
|
||||||
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
SettingsRepository(storage, scope)
|
||||||
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 appViewModel: AppViewModel by viewModels { factory }
|
private val accountRepository by lazy {
|
||||||
private val accountViewModel: AccountViewModel by viewModels { factory }
|
val storage = AppStore(this@MainActivity)
|
||||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
val mediaRepository = MediaRepository(settingsRepository)
|
||||||
private val profileViewModel: ProfileViewModel by viewModels { factory }
|
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
private val relayViewModel: RelayViewModel by viewModels { factory }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
@@ -102,36 +87,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
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 {
|
setContent {
|
||||||
App(
|
App(
|
||||||
appViewModel = appViewModel,
|
profileCache = profileCache,
|
||||||
accountViewModel = accountViewModel,
|
accountRepository = accountRepository,
|
||||||
chatViewModel = chatViewModel,
|
chatRepository = chatRepository,
|
||||||
profileViewModel = profileViewModel,
|
settingsRepository = settingsRepository,
|
||||||
relayViewModel = relayViewModel,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -21,13 +22,14 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
import su.reya.coop.nostr.roomId
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
private var notificationJob: Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
|
|||||||
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -20,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedListItem
|
import androidx.compose.material3.SegmentedListItem
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
@@ -28,11 +33,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberTooltipState
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -50,26 +53,30 @@ import coop.composeapp.generated.resources.ic_check
|
|||||||
import coop.composeapp.generated.resources.ic_close
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Nip05Address
|
import rust.nostr.sdk.Nip05Address
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactListScreen() {
|
fun ContactListScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val contactList = accountState.contactList
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
@@ -128,24 +135,36 @@ fun ContactListScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
if (contactList.isNotEmpty()) {
|
||||||
|
val contacts = remember(contactList) { contactList.toList() }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
.padding(innerPadding),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
) {
|
) {
|
||||||
if (contactList.isNotEmpty()) {
|
itemsIndexed(contacts) { index, pubkey ->
|
||||||
contactList.forEachIndexed { index, pubkey ->
|
|
||||||
ContactListItem(
|
ContactListItem(
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
index = index,
|
index = index,
|
||||||
total = contactList.size,
|
total = contacts.size,
|
||||||
onClick = {})
|
onClick = {
|
||||||
|
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
|
navigator.navigate(Screen.Chat(room))
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
contactToDelete = pubkey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -168,25 +187,50 @@ fun ContactListScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (openAddContactDialog) {
|
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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
var contact by remember { mutableStateOf("") }
|
var contact by remember { mutableStateOf("") }
|
||||||
var isError by remember { mutableStateOf(false) }
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
@@ -224,10 +268,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
accountViewModel.addContact(contact)
|
||||||
val success = profileViewModel.addContact(contact)
|
onDismissRequest()
|
||||||
if (success) onDismissRequest()
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_check),
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
@@ -282,34 +324,31 @@ fun ContactListItem(
|
|||||||
index: Int,
|
index: Int,
|
||||||
total: Int = 0,
|
total: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = { profileViewModel.removeContact(pubkey) },
|
onLongClick = onLongClick,
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = total
|
count = total
|
||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name,
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.os.Build
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -60,7 +61,6 @@ import androidx.compose.material3.rememberTooltipState
|
|||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -91,53 +91,47 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
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.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalRelayViewModel
|
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.RoomUiState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.Room
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.nostr.RoomKind
|
|
||||||
import su.reya.coop.nostr.ago
|
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.shared.nameFlow
|
import su.reya.coop.uiStateFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.short
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen() {
|
fun HomeScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val appViewModel = LocalAppViewModel.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
|
||||||
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
|
||||||
val chatRooms by chatViewModel.chatRooms.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 isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
@@ -164,8 +158,8 @@ fun HomeScreen() {
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(accountState.signerRequired) {
|
||||||
chatViewModel.getChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
@@ -218,8 +212,8 @@ fun HomeScreen() {
|
|||||||
// User
|
// User
|
||||||
IconButton(onClick = { showBottomSheet = true }) {
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
size = 32.dp,
|
size = 32.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -257,7 +251,9 @@ fun HomeScreen() {
|
|||||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
AnimatedVisibility(
|
||||||
|
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -289,7 +285,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { appViewModel.dismissNotificationBanner() },
|
onClick = { accountViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -328,11 +324,9 @@ fun HomeScreen() {
|
|||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.LoadingIndicator(
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
@@ -382,6 +376,9 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
items(ongoing, key = { it.id }) { room ->
|
items(ongoing, key = { it.id }) { room ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.animateItem()
|
||||||
|
) {
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
@@ -392,6 +389,7 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -400,14 +398,6 @@ fun HomeScreen() {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
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 ->
|
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
@@ -432,8 +422,8 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@@ -443,7 +433,7 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName,
|
text = userProfile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -454,16 +444,15 @@ fun HomeScreen() {
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pubkey?.let {
|
userProfile?.publicKey?.let {
|
||||||
val bech32 = it.toBech32()
|
val bech32 = it.toBech32()
|
||||||
val data =
|
val data = ClipData.newPlainText(bech32, bech32)
|
||||||
ClipData.newPlainText(bech32, bech32)
|
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = shortPubkey)
|
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -479,7 +468,11 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
BottomMenuList(onDismiss = dismissAndRun)
|
BottomMenuList(
|
||||||
|
onDismiss = dismissAndRun,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
chatViewModel = chatViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +480,7 @@ fun HomeScreen() {
|
|||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
onDismissRequest = { accountViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -591,15 +584,9 @@ fun HomeScreen() {
|
|||||||
TextButton(
|
TextButton(
|
||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
accountViewModel.refetchMsgRelays()
|
||||||
relayViewModel.refetchMsgRelays(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
|
||||||
}
|
|
||||||
isBusy = false
|
isBusy = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -613,10 +600,8 @@ fun HomeScreen() {
|
|||||||
Button(
|
Button(
|
||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
accountViewModel.useDefaultMsgRelayList()
|
||||||
relayViewModel.useDefaultMsgRelayList()
|
scope.launch { sheetState.hide() }
|
||||||
sheetState.hide()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -637,34 +622,34 @@ fun HomeScreen() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun NewRequests(requests: List<Room>) {
|
fun NewRequests(requests: List<Room>) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
|
|
||||||
val total = requests.size
|
val total = requests.size
|
||||||
val firstRoom = requests.getOrNull(0)
|
val firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstName by remember(firstRoom) {
|
val firstRoomState by remember(firstRoom?.id) {
|
||||||
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
val secondName by remember(secondRoom) {
|
val secondRoomState by remember(secondRoom?.id) {
|
||||||
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||||
}.collectAsStateWithLifecycle("")
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
total == 1 && firstRoom != null -> {
|
total == 1 && firstRoom != null -> {
|
||||||
val message = firstRoom.lastMessage ?: ""
|
val message = firstRoom.lastMessage ?: ""
|
||||||
"$firstName: $message"
|
"${firstRoomState.name}: $message"
|
||||||
}
|
}
|
||||||
|
|
||||||
total == 2 -> {
|
total == 2 -> {
|
||||||
"$firstName and $secondName"
|
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
total > 2 -> {
|
total > 2 -> {
|
||||||
val othersCount = total - 2
|
val othersCount = total - 2
|
||||||
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
"$firstName, $secondName and $othersText"
|
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> ""
|
else -> ""
|
||||||
@@ -720,14 +705,14 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||||
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(picture = picture, description = displayName)
|
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
@@ -735,7 +720,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -765,10 +750,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomMenuList(
|
fun BottomMenuList(
|
||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
@@ -798,7 +784,14 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { onDismiss { accountViewModel.logout() } },
|
onClick = {
|
||||||
|
onDismiss {
|
||||||
|
accountViewModel.logout(onLogout = {
|
||||||
|
accountViewModel.resetInternalState()
|
||||||
|
chatViewModel.resetInternalState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -30,75 +29,49 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen() {
|
fun ImportScreen(viewModel: AccountViewModel) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val appViewModel = LocalAppViewModel.current
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var password by remember { mutableStateOf("") }
|
||||||
|
var requirePassword by remember { mutableStateOf(false) }
|
||||||
// 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
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
if (result.startsWith("nsec1")) {
|
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
|
||||||
Keys.parse(result)
|
Keys.parse(result)
|
||||||
} else if (result.startsWith("bunker://")) {
|
} else if (result.startsWith("bunker://")) {
|
||||||
NostrConnectUri.parse(result)
|
NostrConnectUri.parse(result)
|
||||||
@@ -108,13 +81,26 @@ fun ImportScreen() {
|
|||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
secret = result
|
secret = result
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
}
|
}
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(secret) {
|
||||||
|
if (secret.startsWith("ncryptsec1")) {
|
||||||
|
requirePassword = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to Home on successful import (signerRequired becomes false)
|
||||||
|
LaunchedEffect(accountState.signerRequired) {
|
||||||
|
if (accountState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -154,35 +140,6 @@ fun ImportScreen() {
|
|||||||
.padding(top = innerPadding.calculateTopPadding())
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(120.dp)
|
|
||||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Avatar(
|
|
||||||
picture = 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(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -193,7 +150,7 @@ fun ImportScreen() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp)
|
.padding(24.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -210,9 +167,9 @@ fun ImportScreen() {
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
enabled = !isBusy,
|
enabled = !accountState.isImporting,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
@@ -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))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
viewModel.importIdentity(secret, password)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isBusy,
|
enabled = secret.isNotBlank() && !accountState.isImporting,
|
||||||
) {
|
) {
|
||||||
if (isBusy) {
|
if (accountState.isImporting) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
text = "Continue",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,23 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen() {
|
fun MyQrScreen(viewModel: AccountViewModel) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -59,7 +60,7 @@ fun MyQrScreen() {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||||
contentDescription = "My QR"
|
contentDescription = "My QR"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,26 +55,28 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NewChatScreen() {
|
fun NewChatScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val contactList = accountState.contactList
|
||||||
|
|
||||||
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -96,15 +98,17 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = profileViewModel.searchByAddress(query)
|
accountViewModel.searchByAddress(query) { pubkey ->
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = profileViewModel.searchByNostr(query)
|
accountViewModel.searchByNostr(query) { results ->
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
query = ""
|
query = ""
|
||||||
}
|
}
|
||||||
@@ -182,8 +186,8 @@ fun NewChatScreen() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = { Text("Next") },
|
text = { Text("Next") },
|
||||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,13 +294,9 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -304,7 +304,7 @@ fun ReceiverChip(
|
|||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@@ -312,8 +312,8 @@ fun ReceiverChip(
|
|||||||
},
|
},
|
||||||
avatar = {
|
avatar = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "No name",
|
||||||
size = 24.dp
|
size = 24.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -376,13 +376,9 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
@@ -394,15 +390,15 @@ fun ContactListItem(
|
|||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "",
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "Unknown",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,25 @@ package su.reya.coop.screens
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen(viewModel: AccountViewModel) {
|
||||||
val appViewModel = LocalAppViewModel.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
buttonLabel = "Continue",
|
buttonLabel = "Continue",
|
||||||
isBusy = isBusy,
|
isBusy = accountState.isImporting,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
viewModel.createIdentity(name, bio, bytes, type)
|
||||||
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel)
|
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -41,25 +43,33 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen() {
|
fun OnboardingScreen(viewModel: AccountViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Navigate to Home on successful external signer connection
|
||||||
|
LaunchedEffect(accountState.signerRequired) {
|
||||||
|
if (accountState.signerRequired == false) {
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
@@ -157,15 +167,12 @@ fun OnboardingScreen() {
|
|||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
if (viewModel.isExternalSignerAvailable()) {
|
||||||
if (accountViewModel.isExternalSignerAvailable()) {
|
// Connect to the external signer
|
||||||
try {
|
// TODO: show all available signers?
|
||||||
accountViewModel.connectExternalSigner()
|
viewModel.connectExternalSigner()
|
||||||
navigator.navigate(Screen.Home)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
scope.launch {
|
||||||
val result = snackbarHostState.showSnackbar(
|
val result = snackbarHostState.showSnackbar(
|
||||||
message = "External signer not installed. Please install Amber or alternatives.",
|
message = "External signer not installed. Please install Amber or alternatives.",
|
||||||
actionLabel = "Install",
|
actionLabel = "Install",
|
||||||
|
|||||||
@@ -44,40 +44,41 @@ import coop.composeapp.generated.resources.ic_share
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(pubkey: String) {
|
fun ProfileScreen(
|
||||||
|
pubkey: String,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
val profileCache = LocalProfileCache.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
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 = profile?.metadata?.asRecord()
|
||||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||||
|
val picture = metadata?.picture
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (metadata?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (metadata?.website ?: "None"),
|
||||||
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -161,7 +162,8 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
val roomId =
|
||||||
|
chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
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.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_check
|
import coop.composeapp.generated.resources.ic_check
|
||||||
@@ -66,15 +67,14 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalRelayViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayScreen() {
|
fun RelayScreen(viewModel: AccountViewModel) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +96,26 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(relayViewModel.currentUserRelayList())
|
viewModel.loadCurrentUserRelayList()
|
||||||
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
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(
|
Scaffold(
|
||||||
@@ -296,6 +314,7 @@ fun RelayScreen() {
|
|||||||
|
|
||||||
if (openAddRelayDialog) {
|
if (openAddRelayDialog) {
|
||||||
AddRelayDialog(
|
AddRelayDialog(
|
||||||
|
viewModel = viewModel,
|
||||||
onDismissRequest = { openAddRelayDialog = false },
|
onDismissRequest = { openAddRelayDialog = false },
|
||||||
onMsgRelayAdded = { newRelay ->
|
onMsgRelayAdded = { newRelay ->
|
||||||
msgRelayList.add(RelayUrl.parse(newRelay))
|
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||||
@@ -314,20 +333,16 @@ fun RelayScreen() {
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
|
||||||
if (msgRelayList.size == 1) {
|
if (msgRelayList.size == 1) {
|
||||||
|
scope.launch {
|
||||||
snackbarHostState.showSnackbar("You must have at least one relay")
|
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||||
relayToDelete = null
|
|
||||||
return@launch
|
|
||||||
}
|
}
|
||||||
try {
|
relayToDelete = null
|
||||||
relayViewModel.removeMsgRelay(relayToDelete!!)
|
return@TextButton
|
||||||
|
}
|
||||||
|
viewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
|
||||||
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text("Confirm")
|
Text("Confirm")
|
||||||
@@ -345,14 +360,12 @@ fun RelayScreen() {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddRelayDialog(
|
fun AddRelayDialog(
|
||||||
|
viewModel: AccountViewModel,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val relayViewModel = LocalRelayViewModel.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
var relayAddress by remember { mutableStateOf("") }
|
var relayAddress by remember { mutableStateOf("") }
|
||||||
@@ -397,27 +410,25 @@ fun AddRelayDialog(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
relayViewModel.addMsgRelay(relayAddress)
|
viewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
relayViewModel.addInboxRelay(relayAddress)
|
viewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
relayViewModel.addOutboxRelay(relayAddress)
|
viewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_check),
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.nostr.RoomKind
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RequestListScreen() {
|
fun RequestListScreen(viewModel: ChatViewModel) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get all request rooms
|
// Get all request rooms
|
||||||
val requests = remember(chatRooms) {
|
val requests = remember(chatRooms) {
|
||||||
@@ -101,11 +97,9 @@ fun RequestListScreen() {
|
|||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
viewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.LoadingIndicator(
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
|||||||
@@ -2,28 +2,17 @@ package su.reya.coop.screens
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalAppViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileViewModel
|
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen(viewModel: AccountViewModel) {
|
||||||
val appViewModel = LocalAppViewModel.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||||
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
|
||||||
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Update profile",
|
title = "Update profile",
|
||||||
@@ -31,13 +20,11 @@ fun UpdateProfileScreen() {
|
|||||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||||
initialBio = profile?.about ?: "",
|
initialBio = profile?.about ?: "",
|
||||||
initialPicture = profile?.picture,
|
initialPicture = profile?.picture,
|
||||||
isBusy = isBusy,
|
isBusy = isUpdatingProfile,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
viewModel.updateProfile(name, bio, bytes, type)
|
||||||
profileViewModel.updateProfile(name, bio, bytes, type)
|
|
||||||
navigator.goBack()
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -53,7 +54,7 @@ import coil3.compose.AsyncImage
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -70,11 +71,14 @@ fun ProfileEditor(
|
|||||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
isBusy: Boolean = false,
|
isBusy: Boolean = false,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
|
||||||
|
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
@@ -264,9 +268,9 @@ fun ProfileEditor(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.size(ButtonDefaults.MediumContainerHeight),
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
onClick = {
|
onClick = {
|
||||||
val scope = CoroutineScope(Dispatchers.Main)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
try {
|
||||||
|
val bytes = withContext(ioDispatcher) {
|
||||||
(picture as? Uri)?.let {
|
(picture as? Uri)?.let {
|
||||||
context.contentResolver.openInputStream(it)?.readBytes()
|
context.contentResolver.openInputStream(it)?.readBytes()
|
||||||
}
|
}
|
||||||
@@ -274,6 +278,9 @@ fun ProfileEditor(
|
|||||||
val type =
|
val type =
|
||||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||||
onConfirm(name, bio, bytes, type)
|
onConfirm(name, bio, bytes, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar(e.message ?: "Error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && !isBusy
|
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-lifecycle = "2.10.0"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
androidx-splashscreen = "1.2.0"
|
androidx-splashscreen = "1.2.0"
|
||||||
composeMultiplatform = "1.11.0"
|
composeMultiplatform = "1.11.1"
|
||||||
datastorePreferences = "1.2.1"
|
datastorePreferences = "1.2.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
kotlinx-serialization = "1.11.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
ktor = "3.5.0"
|
ktor = "3.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +33,12 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("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("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.17.0")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
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
|
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 {
|
fun PublicKey.short(): String {
|
||||||
val bech32 = toBech32()
|
val bech32 = toBech32()
|
||||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
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.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
@@ -8,6 +11,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -33,8 +37,7 @@ data class Room(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
|
||||||
val id = rumor.roomId()
|
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||||
|
|
||||||
@@ -59,24 +62,48 @@ data class Room(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setKind(kind: RoomKind): Room {
|
fun isGroup(): Boolean = members.size > 1
|
||||||
return this.copy(kind = kind)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCreatedAt(createdAt: Timestamp): Room {
|
data class RoomUiState(
|
||||||
return this.copy(createdAt = createdAt)
|
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 setSubject(subject: String?): Room {
|
return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
|
||||||
return this.copy(subject = subject)
|
val names = profiles.mapIndexed { i, profile ->
|
||||||
|
profile?.name?.sanitizeName() ?: displayMembers[i].short()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLastMessage(message: String?): Room {
|
val name = when {
|
||||||
return this.copy(lastMessage = message)
|
isGroup() -> {
|
||||||
|
val combined = names.joinToString(", ")
|
||||||
|
val extra = members.size - names.size
|
||||||
|
if (extra > 0) "$combined, +$extra" else combined
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isGroup(): Boolean {
|
else -> {
|
||||||
return members.size > 1
|
val first = names.firstOrNull() ?: "Unknown"
|
||||||
|
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomUiState(
|
||||||
|
name = name,
|
||||||
|
picture = profiles.firstOrNull()?.picture,
|
||||||
|
isGroup = isGroup()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,29 +113,28 @@ fun UnsignedEvent.roomId(): Long {
|
|||||||
pubkeys.add(this.author())
|
pubkeys.add(this.author())
|
||||||
pubkeys.addAll(this.tags().publicKeys())
|
pubkeys.addAll(this.tags().publicKeys())
|
||||||
|
|
||||||
// Sort and hash the list of public keys
|
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
|
||||||
val sortedUniqueKeys = pubkeys
|
}
|
||||||
.distinctBy { it.toBech32() }
|
|
||||||
.sortedBy { it.toBech32() }
|
|
||||||
|
|
||||||
return sortedUniqueKeys.hashCode().toLong()
|
fun Timestamp.formatAsTime(): String {
|
||||||
|
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 {
|
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 inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val duration = now - inputInstant
|
val duration = now - inputInstant
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
duration.inWholeSeconds < 60L -> "Now"
|
||||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
|
||||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
|
||||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
|
||||||
else -> {
|
else -> {
|
||||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
val month =
|
val month =
|
||||||
@@ -119,7 +145,7 @@ fun Timestamp.ago(): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Timestamp.formatAsGroupHeader(): String {
|
fun Timestamp.formatAsGroup(): String {
|
||||||
val timeZone = TimeZone.currentSystemDefault()
|
val timeZone = TimeZone.currentSystemDefault()
|
||||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
||||||
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
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -24,6 +25,9 @@ import rust.nostr.sdk.Tag
|
|||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.roomId
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class MessageSyncState(
|
data class MessageSyncState(
|
||||||
@@ -35,19 +39,20 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
private val client: Client? get() = nostr.client
|
private val client: Client? get() = nostr.client
|
||||||
private val signer: UniversalSigner get() = nostr.signer
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
|
||||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
|
||||||
|
|
||||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
val messageSyncState = _messageSyncState.asStateFlow()
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
_messageSyncState.update(update)
|
_messageSyncState.update(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getUserMessages(msgRelayList: Event) {
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
try {
|
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)
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
// Ensure relay connections
|
// Ensure relay connections
|
||||||
@@ -67,6 +72,8 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
target = ReqTarget.manual(target),
|
target = ReqTarget.manual(target),
|
||||||
id = "gift-wraps"
|
id = "gift-wraps"
|
||||||
)
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -114,7 +121,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
setCachedRumor(event.id(), unsignedEvent)
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
return unsignedEvent
|
return unsignedEvent
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -125,8 +134,10 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
val filter = Filter().identifier(giftId.toHex())
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
val event = client?.database()?.query(filter)?.first()
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +161,9 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
.finalizeAsync(Keys.generate())
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Throwable) {
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,36 +178,35 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
// Get all DM events
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)?.toVec() ?: return null
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
events
|
events
|
||||||
?.toVec()
|
.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
.forEach { rumor ->
|
||||||
?.forEach { event ->
|
val id = rumor.roomId()
|
||||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
val isFromMe = rumor.author() == userPubkey
|
||||||
val existingRoom = roomsMap[newRoom.id]
|
val existing = roomsMap[id]
|
||||||
|
val createdAt = rumor.createdAt()
|
||||||
|
|
||||||
// Check if the room already exists
|
// If the room is new or the current rumor is newer than the existing one
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
|
||||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
|
||||||
.customTag(rTag, newRoom.id.toString())
|
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
|
||||||
|
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
|
||||||
// Determine if it's an ongoing room
|
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
|
||||||
val isOngoing =
|
// If it's an older rumor but sent by the user, mark the room as Ongoing
|
||||||
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
|
||||||
|
|
||||||
// Append room to map
|
|
||||||
roomsMap[newRoom.id] =
|
|
||||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to get chat rooms: ${e.message}")
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
return null
|
return null
|
||||||
@@ -214,6 +226,8 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
// Filter out events without public keys (receivers)
|
// Filter out events without public keys (receivers)
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -237,6 +251,8 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
connectMsgRelays(event)
|
connectMsgRelays(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -316,9 +332,6 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (output != null) {
|
if (output != null) {
|
||||||
// Keep track of sent events
|
|
||||||
sentEvents[output.id] = emptyList()
|
|
||||||
|
|
||||||
// Keep track of rumor IDs
|
// Keep track of rumor IDs
|
||||||
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
rumorMap[id] = output.id
|
rumorMap[id] = output.id
|
||||||
@@ -329,6 +342,8 @@ class MessageManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package su.reya.coop.nostr
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -9,6 +11,7 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Client
|
import rust.nostr.sdk.Client
|
||||||
import rust.nostr.sdk.ClientBuilder
|
import rust.nostr.sdk.ClientBuilder
|
||||||
@@ -30,24 +33,15 @@ import rust.nostr.sdk.Timestamp
|
|||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
val instance = Nostr()
|
||||||
|
|
||||||
val BOOTSTRAP_RELAYS = listOf(
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.ditto.pub",
|
|
||||||
"wss://user.kindpag.es",
|
|
||||||
)
|
|
||||||
|
|
||||||
val INDEXER_RELAY = listOf(
|
|
||||||
"wss://indexer.coracle.social",
|
|
||||||
)
|
|
||||||
|
|
||||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Nostr {
|
class Nostr(
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) {
|
||||||
var client: Client? = null
|
var client: Client? = null
|
||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
@@ -59,17 +53,18 @@ class Nostr {
|
|||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
suspend fun emitNewEvent(event: UnsignedEvent) {
|
fun emitNewEvent(event: UnsignedEvent) {
|
||||||
_newEvents.emit(event)
|
_newEvents.tryEmit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun init(
|
suspend fun init(dbPath: String, logLevel: LogLevel = LogLevel.WARN) {
|
||||||
dbPath: String,
|
|
||||||
logLevel: LogLevel = LogLevel.WARN
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (isInitialized.value) return
|
if (isInitialized.value) return
|
||||||
|
|
||||||
@@ -79,6 +74,8 @@ class Nostr {
|
|||||||
// Initialize configurations for nostr client
|
// Initialize configurations for nostr client
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
val gossip = NostrGossip.inMemory()
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Initialize the authenticator
|
||||||
val authenticator = SignerAuthenticator(signer)
|
val authenticator = SignerAuthenticator(signer)
|
||||||
val idleTimeout = Duration.parse("5m")
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
@@ -103,21 +100,14 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun waitUntilInitialized() {
|
suspend fun waitUntilInitialized() {
|
||||||
isInitialized.first { it }
|
withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
|
||||||
|
?: throw IllegalStateException("Nostr initialization timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
relays.connectBootstrapRelays()
|
relays.connectBootstrapRelays()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reconnect() {
|
|
||||||
relays.reconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect() {
|
|
||||||
relays.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun prune() {
|
suspend fun prune() {
|
||||||
try {
|
try {
|
||||||
client?.database()?.wipe()
|
client?.database()?.wipe()
|
||||||
@@ -136,7 +126,7 @@ class Nostr {
|
|||||||
|
|
||||||
fun isSignedByUser(event: Event): Boolean {
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
return try {
|
return try {
|
||||||
signer.currentUser == event.author()
|
signer.publicKeyFlow.value == event.author()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to check if event is signed by user: ${e.message}")
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
false
|
false
|
||||||
@@ -153,11 +143,11 @@ class Nostr {
|
|||||||
val processedEvent = mutableSetOf<EventId>()
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
val notifications = client?.notifications() ?: return@supervisorScope
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
val giftWrapQueue = Channel<Event>(1024)
|
||||||
var processedCount = 0
|
var processedCount = 0
|
||||||
var eoseReceived = false
|
var eoseReceived = false
|
||||||
|
|
||||||
launch(Dispatchers.Default) {
|
launch(defaultDispatcher) {
|
||||||
for (event in giftWrapQueue) {
|
for (event in giftWrapQueue) {
|
||||||
val rumor = messages.extractRumor(event)
|
val rumor = messages.extractRumor(event)
|
||||||
processedCount++
|
processedCount++
|
||||||
@@ -184,8 +174,6 @@ class Nostr {
|
|||||||
|
|
||||||
when (notification) {
|
when (notification) {
|
||||||
is ClientNotification.Message -> {
|
is ClientNotification.Message -> {
|
||||||
val relayUrl = notification.relayUrl
|
|
||||||
|
|
||||||
when (val message = notification.message.asEnum()) {
|
when (val message = notification.message.asEnum()) {
|
||||||
is RelayMessageEnum.EventMsg -> {
|
is RelayMessageEnum.EventMsg -> {
|
||||||
val event = message.event
|
val event = message.event
|
||||||
@@ -208,7 +196,7 @@ class Nostr {
|
|||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
val pubkeys = event.tags().publicKeys()
|
val pubkeys = event.tags().publicKeys()
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
profiles.getMutualContacts(pubkeys)
|
profiles.syncMutualContacts(pubkeys)
|
||||||
// Emit contact list update
|
// Emit contact list update
|
||||||
onContactListUpdate(pubkeys)
|
onContactListUpdate(pubkeys)
|
||||||
}
|
}
|
||||||
@@ -217,6 +205,7 @@ class Nostr {
|
|||||||
KindStandard.INBOX_RELAYS -> {
|
KindStandard.INBOX_RELAYS -> {
|
||||||
// Get all gift wrap events for the current user
|
// Get all gift wrap events for the current user
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = true) }
|
||||||
messages.getUserMessages(msgRelayList = event)
|
messages.getUserMessages(msgRelayList = event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 -> {
|
else -> {
|
||||||
/* Ignore other message types */
|
/* Ignore other message types */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import rust.nostr.sdk.AckPolicy
|
import rust.nostr.sdk.AckPolicy
|
||||||
@@ -26,17 +27,28 @@ import rust.nostr.sdk.ReqTarget
|
|||||||
import rust.nostr.sdk.SendEventTarget
|
import rust.nostr.sdk.SendEventTarget
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
class ProfileManager(private val nostr: Nostr) {
|
class ProfileManager(private val nostr: Nostr) {
|
||||||
private val client: Client? get() = nostr.client
|
private val client: Client? get() = nostr.client
|
||||||
private val signer: UniversalSigner get() = nostr.signer
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
private val httpClient by lazy { HttpClient() }
|
||||||
|
|
||||||
private val _metadataUpdates =
|
private val _metadataUpdates =
|
||||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
private val _contactListUpdates =
|
||||||
|
MutableSharedFlow<List<PublicKey>>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
@@ -49,7 +61,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
suspend fun getUserMetadata() {
|
suspend fun getUserMetadata() {
|
||||||
try {
|
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
|
// Get the latest metadata event
|
||||||
val metadataFilter =
|
val metadataFilter =
|
||||||
@@ -68,29 +81,24 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
|
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||||
|
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
client?.sync(filter, relays)
|
||||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
} catch (e: CancellationException) {
|
||||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
throw e
|
||||||
}
|
|
||||||
|
|
||||||
client?.subscribe(
|
|
||||||
target = ReqTarget.manual(target),
|
|
||||||
id = "mutual-contacts",
|
|
||||||
closeOn = opts,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} 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,
|
bio: String? = null,
|
||||||
picture: String? = null
|
picture: String? = null
|
||||||
): Metadata {
|
): Metadata {
|
||||||
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Get the latest metadata event
|
||||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
val newRecord = record.copy(
|
|
||||||
|
// Build a new metadata based on old records
|
||||||
|
val newMetadata = Metadata.fromRecord(
|
||||||
|
record.copy(
|
||||||
displayName = name ?: record.displayName,
|
displayName = name ?: record.displayName,
|
||||||
about = bio ?: record.about,
|
about = bio ?: record.about,
|
||||||
picture = picture ?: record.picture
|
picture = picture ?: record.picture
|
||||||
)
|
)
|
||||||
val newMetadata = Metadata.fromRecord(newRecord)
|
)
|
||||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
|
||||||
|
|
||||||
|
// Send the new metadata event
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = event,
|
event = event,
|
||||||
target = SendEventTarget.broadcast(),
|
target = SendEventTarget.broadcast(),
|
||||||
@@ -165,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return newMetadata
|
return newMetadata
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -177,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
Metadata.fromJson(event.content())
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to get latest metadata: ${e.message}")
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
null
|
null
|
||||||
@@ -199,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to get all cache metadata: ${e.message}")
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
return emptyMap()
|
return emptyMap()
|
||||||
@@ -218,11 +238,13 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
|
|
||||||
// Construct request target
|
// Construct request target
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -238,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
target = SendEventTarget.broadcast(),
|
target = SendEventTarget.broadcast(),
|
||||||
ackPolicy = AckPolicy.none(),
|
ackPolicy = AckPolicy.none(),
|
||||||
)
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -249,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
val bodyString: String = response.body()
|
val bodyString: String = response.body()
|
||||||
|
|
||||||
return Nip05Profile.fromJson(address, bodyString)
|
return Nip05Profile.fromJson(address, bodyString)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -257,9 +283,11 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
suspend fun searchByAddress(query: String): PublicKey {
|
suspend fun searchByAddress(query: String): PublicKey {
|
||||||
try {
|
try {
|
||||||
val address = Nip05Address.parse(query)
|
val address = Nip05Address.parse(query)
|
||||||
val profile = profileFromAddress(HttpClient(), address)
|
val profile = profileFromAddress(httpClient, address)
|
||||||
|
|
||||||
return profile.publicKey()
|
return profile.publicKey()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -295,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -304,7 +334,7 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
try {
|
try {
|
||||||
val filter = Filter().author(pubkey).limit(3u)
|
val filter = Filter().author(pubkey).limit(3u)
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
target[RelayUrl.parse(relay)] = listOf(filter)
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return events?.first()?.createdAt()
|
return events?.first()?.createdAt()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -331,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||||
|
|
||||||
return pubkeys.contains(pubkey)
|
return pubkeys.contains(pubkey)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -352,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return contacts.toSet()
|
return contacts.toSet()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList
|
|||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
class RelayManager(private val nostr: Nostr) {
|
class RelayManager(private val nostr: Nostr) {
|
||||||
|
companion object {
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
}
|
||||||
|
|
||||||
private val client: Client? get() = nostr.client
|
private val client: Client? get() = nostr.client
|
||||||
private val signer: UniversalSigner get() = nostr.signer
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
client?.addRelay(RelayUrl.parse(url))
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
}
|
}
|
||||||
NostrManager.INDEXER_RELAY.forEach { url ->
|
INDEXER_RELAY.forEach { url ->
|
||||||
client?.addRelay(
|
client?.addRelay(
|
||||||
url = RelayUrl.parse(url),
|
url = RelayUrl.parse(url),
|
||||||
capabilities = RelayCapabilities.gossip()
|
capabilities = RelayCapabilities.gossip()
|
||||||
@@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun reconnect() {
|
suspend fun reconnect() {
|
||||||
NostrManager.ALL_RELAYS.forEach { url ->
|
ALL_RELAYS.forEach { url ->
|
||||||
try {
|
try {
|
||||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
if (relay != null) {
|
if (relay != null) {
|
||||||
@@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun disconnect() {
|
suspend fun disconnect() {
|
||||||
NostrManager.ALL_RELAYS.forEach { url ->
|
ALL_RELAYS.forEach { url ->
|
||||||
try {
|
try {
|
||||||
client?.disconnectRelay(RelayUrl.parse(url))
|
client?.disconnectRelay(RelayUrl.parse(url))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -104,6 +118,32 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
return msgRelayList
|
return msgRelayList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
@@ -134,8 +174,9 @@ class RelayManager(private val nostr: Nostr) {
|
|||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
val events = client?.database()?.query(filter)
|
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) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
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)
|
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
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
@Volatile
|
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||||
var currentUser: PublicKey? = null
|
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
@@ -28,16 +29,14 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
/**
|
/**
|
||||||
* Switch to a new signer.
|
* Switch to a new signer.
|
||||||
*/
|
*/
|
||||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
suspend fun switch(newSigner: AsyncNostrSigner) {
|
||||||
val pubkey = try {
|
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
|
||||||
withTimeoutOrNull(20.seconds) {
|
?: throw IllegalStateException("Failed to get public key from signer")
|
||||||
newSigner.getPublicKeyAsync()
|
|
||||||
}
|
mutex.withLock {
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get public key from signer", e)
|
|
||||||
}
|
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
currentUser = pubkey
|
_publicKeyFlow.value = pubkey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
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