.
This commit is contained in:
@@ -26,7 +26,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
@@ -35,6 +38,8 @@ 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 kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.repository.AccountRepository
|
||||||
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -49,6 +54,7 @@ import su.reya.coop.screens.ScanScreen
|
|||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
|
|
||||||
@@ -56,15 +62,6 @@ val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
|||||||
error("No ProfileCache provided")
|
error("No ProfileCache provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
|
||||||
error("No ChatViewModel provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
|
||||||
error("No AccountViewModel provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -81,9 +78,32 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
profileCache: ProfileCache,
|
profileCache: ProfileCache,
|
||||||
chatViewModel: ChatViewModel,
|
accountRepository: AccountRepository,
|
||||||
accountViewModel: AccountViewModel,
|
chatRepository: ChatRepository,
|
||||||
) {
|
) {
|
||||||
|
val viewModelFactory = remember {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
val result = when {
|
||||||
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
|
||||||
|
chatRepository
|
||||||
|
)
|
||||||
|
|
||||||
|
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
|
||||||
|
accountRepository
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return result as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
@@ -141,7 +161,7 @@ fun App(
|
|||||||
fun handleIntent(intent: Intent) {
|
fun handleIntent(intent: Intent) {
|
||||||
val screen = Screen.fromIntent(intent)
|
val screen = Screen.fromIntent(intent)
|
||||||
// Prevent pushing the same screen
|
// Prevent pushing the same screen
|
||||||
if (screen != null && backStack.lastOrNull() != screen) {
|
if ((screen != null) && (backStack.lastOrNull() != screen)) {
|
||||||
navigator.navigate(screen)
|
navigator.navigate(screen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,8 +195,6 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalProfileCache provides profileCache,
|
LocalProfileCache provides profileCache,
|
||||||
LocalChatViewModel provides chatViewModel,
|
|
||||||
LocalAccountViewModel provides accountViewModel,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
@@ -197,43 +215,62 @@ fun App(
|
|||||||
),
|
),
|
||||||
entryProvider = entryProvider {
|
entryProvider = entryProvider {
|
||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen()
|
HomeScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.RequestList> {
|
entry<Screen.RequestList> {
|
||||||
RequestListScreen()
|
RequestListScreen(chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Onboarding> {
|
entry<Screen.Onboarding> {
|
||||||
OnboardingScreen()
|
OnboardingScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Import> {
|
entry<Screen.Import> {
|
||||||
ImportScreen()
|
ImportScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.NewIdentity> {
|
entry<Screen.NewIdentity> {
|
||||||
NewIdentityScreen()
|
NewIdentityScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Chat> { key ->
|
entry<Screen.Chat> { key ->
|
||||||
ChatScreen(id = key.id, screening = key.screening)
|
val factory = remember(key) {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return ChatScreenViewModel(
|
||||||
|
key.id,
|
||||||
|
key.screening,
|
||||||
|
accountRepository,
|
||||||
|
chatRepository
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatScreen(
|
||||||
|
viewModel<ChatScreenViewModel>(
|
||||||
|
key = key.id.toString(),
|
||||||
|
factory = factory
|
||||||
|
),
|
||||||
|
accountViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.NewChat> {
|
entry<Screen.NewChat> {
|
||||||
NewChatScreen()
|
NewChatScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Profile> { key ->
|
entry<Screen.Profile> { key ->
|
||||||
ProfileScreen(pubkey = key.pubkey)
|
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.UpdateProfile> {
|
entry<Screen.UpdateProfile> {
|
||||||
UpdateProfileScreen()
|
UpdateProfileScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Scan> {
|
entry<Screen.Scan> {
|
||||||
ScanScreen()
|
ScanScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.MyQr> {
|
entry<Screen.MyQr> {
|
||||||
MyQrScreen()
|
MyQrScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.ContactList> {
|
entry<Screen.ContactList> {
|
||||||
ContactListScreen()
|
ContactListScreen(accountViewModel, chatViewModel)
|
||||||
}
|
}
|
||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen()
|
RelayScreen(accountViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import androidx.activity.viewModels
|
|||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
import su.reya.coop.nostr.NostrManager
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.repository.AccountRepository
|
||||||
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
@@ -25,28 +28,40 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||||
|
private val scope = MainScope()
|
||||||
|
|
||||||
|
private val accountRepository by lazy {
|
||||||
|
val storage = AppStore(this@MainActivity)
|
||||||
|
val mediaRepository = MediaRepository()
|
||||||
|
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
|
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val chatRepository by lazy {
|
||||||
|
val mediaRepository = MediaRepository()
|
||||||
|
ChatRepository(NostrManager.instance, mediaRepository, scope)
|
||||||
|
}
|
||||||
|
|
||||||
private val factory by lazy {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
private val storage = AppStore(this@MainActivity)
|
|
||||||
private val mediaRepository = MediaRepository()
|
|
||||||
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
|
|
||||||
private val androidSigner =
|
|
||||||
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
|
||||||
private val accountViewModel =
|
|
||||||
AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner)
|
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
return when {
|
val result = when {
|
||||||
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
|
||||||
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel
|
chatRepository
|
||||||
|
)
|
||||||
|
|
||||||
|
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
|
||||||
|
accountRepository
|
||||||
|
)
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
} as T
|
}
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return result as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val chatViewModel: ChatViewModel by viewModels { factory }
|
|
||||||
private val accountViewModel: AccountViewModel by viewModels { factory }
|
private val accountViewModel: AccountViewModel by viewModels { factory }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -92,8 +107,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
App(
|
App(
|
||||||
profileCache = profileCache,
|
profileCache = profileCache,
|
||||||
chatViewModel = chatViewModel,
|
accountRepository = accountRepository,
|
||||||
accountViewModel = accountViewModel,
|
chatRepository = chatRepository,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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
|
||||||
@@ -54,28 +53,26 @@ 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.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
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 profileCache = LocalProfileCache.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
|
|
||||||
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var openAddContactDialog by remember { mutableStateOf(false) }
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
@@ -192,7 +189,10 @@ fun ContactListScreen() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (openAddContactDialog) {
|
if (openAddContactDialog) {
|
||||||
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
AddContactDialog(
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onDismissRequest = { openAddContactDialog = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contactToDelete != null) {
|
if (contactToDelete != null) {
|
||||||
@@ -221,16 +221,15 @@ fun ContactListScreen() {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
fun AddContactDialog(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val profileCache = LocalProfileCache.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
@@ -103,17 +101,20 @@ import su.reya.coop.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.uiStateFlow
|
import su.reya.coop.uiStateFlow
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen() {
|
fun HomeScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
@@ -459,7 +460,11 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
BottomMenuList(onDismiss = dismissAndRun)
|
BottomMenuList(
|
||||||
|
onDismiss = dismissAndRun,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
chatViewModel = chatViewModel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,12 +739,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 profileCache = LocalProfileCache.current
|
|
||||||
val chatViewModel = LocalChatViewModel.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) },
|
||||||
|
|||||||
@@ -50,23 +50,20 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
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 su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
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.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@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 accountViewModel = LocalAccountViewModel.current
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
var requirePassword by remember { mutableStateOf(false) }
|
var requirePassword by remember { mutableStateOf(false) }
|
||||||
@@ -250,7 +247,7 @@ fun ImportScreen() {
|
|||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
accountViewModel.importIdentity(secret, password)
|
viewModel.importIdentity(secret, password)
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -21,16 +21,15 @@ 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 by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ 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.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
@@ -64,18 +62,19 @@ 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 profileCache = LocalProfileCache.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
|
|
||||||
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ 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.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen(viewModel: AccountViewModel) {
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
@@ -20,7 +19,7 @@ fun NewIdentityScreen() {
|
|||||||
isBusy = accountState.isImporting,
|
isBusy = accountState.isImporting,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
accountViewModel.createIdentity(name, bio, bytes, type)
|
viewModel.createIdentity(name, bio, bytes, type)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,26 +43,24 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
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 accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Navigate to Home on successful external signer connection
|
// Navigate to Home on successful external signer connection
|
||||||
@@ -176,10 +174,10 @@ fun OnboardingScreen() {
|
|||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (accountViewModel.isExternalSignerAvailable()) {
|
if (viewModel.isExternalSignerAvailable()) {
|
||||||
// Connect to the external signer
|
// Connect to the external signer
|
||||||
// TODO: show all available signers?
|
// TODO: show all available signers?
|
||||||
accountViewModel.connectExternalSigner()
|
viewModel.connectExternalSigner()
|
||||||
} else {
|
} else {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = snackbarHostState.showSnackbar(
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ 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.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
@@ -52,17 +51,20 @@ import su.reya.coop.Screen
|
|||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(pubkey: String) {
|
fun ProfileScreen(
|
||||||
|
pubkey: String,
|
||||||
|
chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
|
||||||
|
|||||||
@@ -67,16 +67,13 @@ 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.LocalProfileCache
|
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
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 profileCache = LocalProfileCache.current
|
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -99,12 +96,12 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
accountViewModel.loadCurrentUserRelayList()
|
viewModel.loadCurrentUserRelayList()
|
||||||
accountViewModel.loadCurrentUserMsgRelayList()
|
viewModel.loadCurrentUserMsgRelayList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle()
|
val loadedRelayList by viewModel.currentUserRelayList.collectAsStateWithLifecycle()
|
||||||
val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
|
val loadedMsgRelayList by viewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(loadedRelayList) {
|
LaunchedEffect(loadedRelayList) {
|
||||||
if (loadedRelayList.isNotEmpty()) {
|
if (loadedRelayList.isNotEmpty()) {
|
||||||
@@ -316,6 +313,7 @@ fun RelayScreen() {
|
|||||||
|
|
||||||
if (openAddRelayDialog) {
|
if (openAddRelayDialog) {
|
||||||
AddRelayDialog(
|
AddRelayDialog(
|
||||||
|
viewModel = viewModel,
|
||||||
onDismissRequest = { openAddRelayDialog = false },
|
onDismissRequest = { openAddRelayDialog = false },
|
||||||
onMsgRelayAdded = { newRelay ->
|
onMsgRelayAdded = { newRelay ->
|
||||||
msgRelayList.add(RelayUrl.parse(newRelay))
|
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||||
@@ -341,7 +339,7 @@ fun RelayScreen() {
|
|||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
return@TextButton
|
return@TextButton
|
||||||
}
|
}
|
||||||
accountViewModel.removeMsgRelay(relayToDelete!!)
|
viewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
}
|
}
|
||||||
@@ -361,14 +359,12 @@ fun RelayScreen() {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddRelayDialog(
|
fun AddRelayDialog(
|
||||||
|
viewModel: AccountViewModel,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val accountViewModel = LocalAccountViewModel.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("") }
|
||||||
@@ -416,17 +412,17 @@ fun AddRelayDialog(
|
|||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
accountViewModel.addMsgRelay(relayAddress)
|
viewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
accountViewModel.addInboxRelay(relayAddress)
|
viewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
accountViewModel.addOutboxRelay(relayAddress)
|
viewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.RoomKind
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RequestListScreen() {
|
fun RequestListScreen(viewModel: ChatViewModel) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get all request rooms
|
// Get all request rooms
|
||||||
val requests = remember(chatRooms) {
|
val requests = remember(chatRooms) {
|
||||||
@@ -102,7 +98,7 @@ fun RequestListScreen() {
|
|||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
chatViewModel.refreshChatRooms()
|
viewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ 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.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
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 accountViewModel = LocalAccountViewModel.current
|
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
|
||||||
val profile = currentUser?.metadata?.asRecord()
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Update profile",
|
title = "Update profile",
|
||||||
@@ -25,7 +23,7 @@ fun UpdateProfileScreen() {
|
|||||||
isBusy = isUpdatingProfile,
|
isBusy = isUpdatingProfile,
|
||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
accountViewModel.updateProfile(name, bio, bytes, type)
|
viewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -31,21 +30,20 @@ import coop.composeapp.generated.resources.ic_check_circle
|
|||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.humanReadable
|
import su.reya.coop.humanReadable
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ScreenerCard(room: Room) {
|
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
|
||||||
val pubkey = room.members.firstOrNull() ?: return
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
|
||||||
|
|
||||||
var isContact by remember { mutableStateOf(false) }
|
var isContact by remember { mutableStateOf(false) }
|
||||||
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
@@ -56,11 +54,11 @@ fun ScreenerCard(room: Room) {
|
|||||||
|
|
||||||
LaunchedEffect(pubkey) {
|
LaunchedEffect(pubkey) {
|
||||||
// Check contact
|
// Check contact
|
||||||
accountViewModel.verifyContact(pubkey) { isContact = it }
|
viewModel.verifyContact(pubkey) { isContact = it }
|
||||||
// Get mutual contacts
|
// Get mutual contacts
|
||||||
accountViewModel.mutualContacts(pubkey) { mutualContacts = it }
|
viewModel.mutualContacts(pubkey) { mutualContacts = it }
|
||||||
// Get the last activity
|
// Get the last activity
|
||||||
accountViewModel.verifyActivity(pubkey) { lastActivity = it }
|
viewModel.verifyActivity(pubkey) { lastActivity = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -64,9 +62,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.LocalAccountViewModel
|
|
||||||
import su.reya.coop.LocalChatViewModel
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
@@ -74,31 +69,28 @@ import su.reya.coop.Room
|
|||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.formatAsGroupHeader
|
||||||
import su.reya.coop.uiStateFlow
|
|
||||||
import su.reya.coop.roomId
|
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.uiStateFlow
|
||||||
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
id: Long,
|
viewModel: ChatScreenViewModel,
|
||||||
screening: Boolean = false,
|
accountViewModel: AccountViewModel,
|
||||||
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
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 profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val chatViewModel = LocalChatViewModel.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Get current user
|
val id = viewModel.id
|
||||||
val accountViewModel = LocalAccountViewModel.current
|
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
|
||||||
val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Get chat room by ID
|
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
|
||||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
// Show empty screen
|
// Show empty screen
|
||||||
@@ -118,12 +110,13 @@ fun ChatScreen(
|
|||||||
|
|
||||||
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
|
||||||
var requireScreening by remember { mutableStateOf(screening) }
|
|
||||||
|
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
var text by remember { mutableStateOf("") }
|
||||||
|
val loading = viewModel.loading
|
||||||
|
val newOtherMessages = viewModel.newOtherMessages
|
||||||
|
val requireScreening = viewModel.requireScreening
|
||||||
|
|
||||||
|
val messages = viewModel.messages
|
||||||
val groupedMessages =
|
val groupedMessages =
|
||||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
||||||
|
|
||||||
@@ -138,7 +131,7 @@ fun ChatScreen(
|
|||||||
val type = context.contentResolver.getType(uri)
|
val type = context.contentResolver.getType(uri)
|
||||||
|
|
||||||
// Send message (handles errors internally via ViewModel)
|
// Send message (handles errors internally via ViewModel)
|
||||||
chatViewModel.sendFileMessage(id, file, type)
|
viewModel.sendFileMessage(file, type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,29 +150,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
LaunchedEffect(id) {
|
||||||
// Get messages
|
|
||||||
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
|
|
||||||
messages.clear()
|
|
||||||
messages.addAll(initialMessages)
|
|
||||||
|
|
||||||
// Stop loading spinner
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get msg relays for each member
|
|
||||||
chatViewModel.chatRoomConnect(id)
|
|
||||||
|
|
||||||
// Handle new messages
|
|
||||||
chatViewModel.newEvents.collect { event ->
|
|
||||||
if (event.roomId() == id) {
|
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
|
||||||
messages.add(0, event)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If the event is not in the current room, it's a new message from another user
|
|
||||||
newOtherMessages++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
@@ -256,7 +226,7 @@ fun ChatScreen(
|
|||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (requireScreening) {
|
if (requireScreening) {
|
||||||
room?.let { ScreenerCard(it) }
|
room?.let { ScreenerCard(accountViewModel, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
@@ -340,7 +310,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { requireScreening = false },
|
onClick = { viewModel.requireScreening = false },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.size(ButtonDefaults.MediumContainerHeight)
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
@@ -358,7 +328,7 @@ fun ChatScreen(
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
chatViewModel.sendMessage(id, text)
|
viewModel.sendMessage(text)
|
||||||
text = ""
|
text = ""
|
||||||
},
|
},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
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.filter
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.EncryptedSecretKey
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.NostrConnect
|
||||||
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.AppStorage
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.viewmodel.ErrorHost
|
||||||
|
import su.reya.coop.viewmodel.createErrorHost
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class AccountState(
|
||||||
|
val signerRequired: Boolean? = null,
|
||||||
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
val isImporting: Boolean = false,
|
||||||
|
val importError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AccountRepository(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val storage: AppStorage,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
) : ErrorHost by createErrorHost() {
|
||||||
|
companion object {
|
||||||
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
|
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AccountState())
|
||||||
|
val state: StateFlow<AccountState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _isUpdatingProfile = MutableStateFlow(false)
|
||||||
|
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||||
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
|
||||||
|
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> =
|
||||||
|
_currentUserRelayList.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
|
||||||
|
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = _currentUserMsgRelayList.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
login()
|
||||||
|
observeContactList()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
onSignerReady()
|
||||||
|
}.onFailure { e ->
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
nostr.signer.switch(Keys.generate())
|
||||||
|
nostr.prune()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Logout encountered an error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
storage.clear(KEY_USER_SIGNER)
|
||||||
|
storage.clear(KEY_BANNER_DISMISSED)
|
||||||
|
onLogout()
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
scope.launch {
|
||||||
|
storage.set(KEY_BANNER_DISMISSED, "true")
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
|
||||||
|
val secret = storage.getSecret(KEY_APP_KEYS)
|
||||||
|
if (secret != null) return@withContext Keys.parse(secret)
|
||||||
|
val keys = Keys.generate()
|
||||||
|
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(
|
||||||
|
secret: String,
|
||||||
|
password: String? = null
|
||||||
|
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
|
||||||
|
when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret) to null
|
||||||
|
|
||||||
|
secret.startsWith("ncryptsec1") -> {
|
||||||
|
if (password == null) throw IllegalArgumentException("Password is required")
|
||||||
|
val enc = EncryptedSecretKey.fromBech32(secret)
|
||||||
|
val decrypted = enc.decrypt(password)
|
||||||
|
val keys = Keys(decrypted)
|
||||||
|
keys to keys.secretKey().toBech32()
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, null) to null
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("nip55://") -> {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
|
||||||
|
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||||
|
val packageName = parts[0]
|
||||||
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
|
handler.setPackageName(packageName)
|
||||||
|
ExternalSignerProxy(handler, pubkey) to null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun importIdentity(secret: String, password: String? = null) {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
|
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Import failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectExternalSigner() {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val handler =
|
||||||
|
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
|
||||||
|
val permissions = SignerPermissions.toJson(
|
||||||
|
listOf(
|
||||||
|
SignerPermissions.signEvent(0),
|
||||||
|
SignerPermissions.signEvent(3),
|
||||||
|
SignerPermissions.signEvent(10000),
|
||||||
|
SignerPermissions.signEvent(10050),
|
||||||
|
SignerPermissions.signEvent(10063),
|
||||||
|
SignerPermissions.signEvent(22242),
|
||||||
|
SignerPermissions.signEvent(30030),
|
||||||
|
SignerPermissions.signEvent(30315),
|
||||||
|
SignerPermissions.nip44Encrypt(),
|
||||||
|
SignerPermissions.nip44Decrypt(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
|
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, uri)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("External signer connection failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
_state.update { it.copy(isImporting = true, importError = null) }
|
||||||
|
try {
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
nostr.profiles.createIdentity(
|
||||||
|
keys = keys,
|
||||||
|
name = name,
|
||||||
|
bio = bio,
|
||||||
|
picture = avatarUrl
|
||||||
|
)
|
||||||
|
onSignerReady()
|
||||||
|
|
||||||
|
storage.setSecret(KEY_USER_SIGNER, secret)
|
||||||
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Identity creation failed: ${e.message}")
|
||||||
|
_state.update { it.copy(isImporting = false, importError = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSignerReady() {
|
||||||
|
getUserMetadata()
|
||||||
|
checkRelayList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 getUserMetadata() {
|
||||||
|
scope.launch {
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
scope.launch {
|
||||||
|
_isUpdatingProfile.value = true
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(
|
||||||
|
nostr.signer.get(),
|
||||||
|
it,
|
||||||
|
contentType ?: "image/jpeg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
_isUpdatingProfile.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_isUpdatingProfile.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeContactList() {
|
||||||
|
scope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(address: String) {
|
||||||
|
scope.launch {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.profiles.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Invalid contact address: ${e.message}")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubkey in _contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = _contactList.value + pubkey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it + pubkey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
scope.launch {
|
||||||
|
if (publicKey !in _contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = _contactList.value - publicKey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.searchByAddress(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
onResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.searchByNostr(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
onResult(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.verifyActivity(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
onResult(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.verifyContact(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
onResult(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.profiles.mutualContacts(pubkey))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
onResult(emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun checkRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
||||||
|
|
||||||
|
val relays = withTimeoutOrNull(10.seconds) {
|
||||||
|
var result = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
while (result.isEmpty()) {
|
||||||
|
delay(500.milliseconds)
|
||||||
|
result = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refetchMsgRelays() {
|
||||||
|
scope.launch {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||||
|
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun useDefaultMsgRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCurrentUserRelayList() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
|
||||||
|
} 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 currentUser =
|
||||||
|
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun currentUserMsgRelayListInternal(): 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 = currentUserMsgRelayListInternal().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 = currentUserMsgRelayListInternal().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import su.reya.coop.viewmodel.ErrorHost
|
||||||
|
import su.reya.coop.viewmodel.createErrorHost
|
||||||
|
|
||||||
|
data class ChatState(
|
||||||
|
val rooms: Map<Long, Room> = emptyMap(),
|
||||||
|
val isPartialProcessedGiftWrap: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ChatRepository(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) : ErrorHost by createErrorHost() {
|
||||||
|
private val _state = MutableStateFlow(ChatState())
|
||||||
|
val state = _state.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
ChatState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 100,
|
||||||
|
onBufferOverflow = BufferOverflow.SUSPEND
|
||||||
|
)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
|
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { syncState ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (syncState.processedCount > 0 || !syncState.isSyncing) {
|
||||||
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 100 messages OR when sync is fully done
|
||||||
|
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new messages
|
||||||
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _state.value.rooms[roomId]
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.tryEmit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.value
|
||||||
|
?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Construct the rumor event
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _state.value.rooms[id]
|
||||||
|
|
||||||
|
// If the room already exists, return its ID
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a room from the rumor event
|
||||||
|
val room = Room.new(rumor, currentUser)
|
||||||
|
|
||||||
|
// Update the chat rooms state
|
||||||
|
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
|
||||||
|
|
||||||
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRoom(id: Long): Room? {
|
||||||
|
return _state.value.rooms[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshChatRooms() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
_state.update { currentState ->
|
||||||
|
val newMap = currentState.rooms.toMutableMap()
|
||||||
|
rooms.forEach { room -> newMap[room.id] = room }
|
||||||
|
currentState.copy(rooms = newMap)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
onResult(nostr.messages.getChatRoomMessages(roomId))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
scope.launch {
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.sendMessage(
|
||||||
|
to = room.members,
|
||||||
|
content = message,
|
||||||
|
subject = room.subject,
|
||||||
|
replies = replies,
|
||||||
|
onRumorCreated = { event ->
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
scope.launch { _newEvents.tryEmit(event) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFileMessage(
|
||||||
|
roomId: Long,
|
||||||
|
file: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
replies: List<EventId> = emptyList()
|
||||||
|
) {
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||||
|
if (uri != null) sendMessage(roomId, uri, replies)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("File upload failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
|
if (giftWrapId != null) {
|
||||||
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
return isSent
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val room = currentState.rooms[roomId] ?: return@update currentState
|
||||||
|
val updatedRoom = room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
|
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = emptyMap(),
|
||||||
|
isPartialProcessedGiftWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,604 +1,56 @@
|
|||||||
package su.reya.coop.viewmodel
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.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.PublicKey
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import su.reya.coop.AppStorage
|
|
||||||
import su.reya.coop.Profile
|
import su.reya.coop.Profile
|
||||||
import su.reya.coop.nostr.ExternalSignerHandler
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.nostr.ExternalSignerProxy
|
import su.reya.coop.repository.AccountState
|
||||||
import su.reya.coop.nostr.Nostr
|
|
||||||
import su.reya.coop.nostr.SignerPermissions
|
|
||||||
import su.reya.coop.repository.MediaRepository
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
data class AccountState(
|
|
||||||
val signerRequired: Boolean? = null,
|
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
|
||||||
val isImporting: Boolean = false,
|
|
||||||
val importError: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
class AccountViewModel(
|
class AccountViewModel(
|
||||||
private val nostr: Nostr,
|
private val repository: AccountRepository,
|
||||||
private val storage: AppStorage,
|
) : ViewModel(), ErrorHost by repository {
|
||||||
private val mediaRepository: MediaRepository,
|
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
val state: StateFlow<AccountState> = repository.state
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
||||||
) : ViewModel(), ErrorHost by createErrorHost() {
|
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
|
||||||
companion object {
|
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
|
||||||
private const val KEY_USER_SIGNER = "user_signer"
|
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
|
||||||
private const val KEY_APP_KEYS = "app_keys"
|
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.currentUserRelayList
|
||||||
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = repository.currentUserMsgRelayList
|
||||||
}
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
|
||||||
private val _state = MutableStateFlow(AccountState())
|
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
||||||
val state: StateFlow<AccountState> = _state.asStateFlow()
|
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
|
||||||
|
fun importIdentity(secret: String, password: String? = null) = repository.importIdentity(secret, password)
|
||||||
private val _isUpdatingProfile = MutableStateFlow(false)
|
fun connectExternalSigner() = repository.connectExternalSigner()
|
||||||
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
|
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String? = null) =
|
||||||
|
repository.createIdentity(name, bio, picture, contentType)
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
fun getUserMetadata() = repository.getUserMetadata()
|
||||||
.flatMapLatest { pubkey ->
|
fun updateProfile(name: String? = null, bio: String? = null, picture: ByteArray? = null, contentType: String? = null) =
|
||||||
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
repository.updateProfile(name, bio, picture, contentType)
|
||||||
}
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
fun resetInternalState() = repository.resetInternalState()
|
||||||
|
fun addContact(address: String) = repository.addContact(address)
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
|
||||||
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
|
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) = repository.searchByAddress(query, onResult)
|
||||||
|
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) = repository.searchByNostr(query, onResult)
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) = repository.verifyActivity(pubkey, onResult)
|
||||||
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
|
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) = repository.verifyContact(pubkey, onResult)
|
||||||
|
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) = repository.mutualContacts(pubkey, onResult)
|
||||||
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
|
fun checkRelayList() = repository.checkRelayList()
|
||||||
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> =
|
fun dismissRelayWarning() = repository.dismissRelayWarning()
|
||||||
_currentUserRelayList.asStateFlow()
|
fun refetchMsgRelays() = repository.refetchMsgRelays()
|
||||||
|
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
|
||||||
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
|
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
|
||||||
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = _currentUserMsgRelayList.asStateFlow()
|
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
|
||||||
|
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
|
||||||
init {
|
fun removeRelay(relay: String) = repository.removeRelay(relay)
|
||||||
checkNotificationBannerDismissedStatus()
|
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
|
||||||
login()
|
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
|
||||||
observeContactList()
|
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
|
|
||||||
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login() {
|
|
||||||
viewModelScope.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) }
|
|
||||||
onSignerReady()
|
|
||||||
}.onFailure { e ->
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_state.update { it.copy(signerRequired = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout(onLogout: () -> Unit = {}) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
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() {
|
|
||||||
viewModelScope.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) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
|
||||||
try {
|
|
||||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
|
||||||
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
onSignerReady()
|
|
||||||
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
|
|
||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Import failed: ${e.message}")
|
|
||||||
_state.update { it.copy(isImporting = false, importError = e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connectExternalSigner() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
|
||||||
try {
|
|
||||||
val handler =
|
|
||||||
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
|
||||||
|
|
||||||
val permissions = SignerPermissions.toJson(
|
|
||||||
listOf(
|
|
||||||
SignerPermissions.signEvent(0),
|
|
||||||
SignerPermissions.signEvent(3),
|
|
||||||
SignerPermissions.signEvent(10000),
|
|
||||||
SignerPermissions.signEvent(10050),
|
|
||||||
SignerPermissions.signEvent(10063),
|
|
||||||
SignerPermissions.signEvent(22242),
|
|
||||||
SignerPermissions.signEvent(30030),
|
|
||||||
SignerPermissions.signEvent(30315),
|
|
||||||
SignerPermissions.nip44Encrypt(),
|
|
||||||
SignerPermissions.nip44Decrypt(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
|
||||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
|
||||||
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
|
|
||||||
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
onSignerReady()
|
|
||||||
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, uri)
|
|
||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("External signer connection failed: ${e.message}")
|
|
||||||
_state.update { it.copy(isImporting = false, importError = e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
|
||||||
try {
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
val avatarUrl = picture?.let {
|
|
||||||
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
|
|
||||||
}
|
|
||||||
|
|
||||||
nostr.profiles.createIdentity(
|
|
||||||
keys = keys,
|
|
||||||
name = name,
|
|
||||||
bio = bio,
|
|
||||||
picture = avatarUrl
|
|
||||||
)
|
|
||||||
onSignerReady()
|
|
||||||
|
|
||||||
storage.setSecret(KEY_USER_SIGNER, secret)
|
|
||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Identity creation failed: ${e.message}")
|
|
||||||
_state.update { it.copy(isImporting = false, importError = e.message) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSignerReady() {
|
|
||||||
getUserMetadata()
|
|
||||||
checkRelayList()
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 getUserMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.profiles.getUserMetadata()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: ByteArray? = null,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
viewModelScope.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() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetInternalState() {
|
|
||||||
_contactList.value = emptySet()
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addContact(address: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val pubkey = try {
|
|
||||||
if (address.contains("@")) {
|
|
||||||
nostr.profiles.searchByAddress(address)
|
|
||||||
} else {
|
|
||||||
PublicKey.parse(address)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Invalid contact address: ${e.message}")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pubkey in _contactList.value) return@launch
|
|
||||||
|
|
||||||
try {
|
|
||||||
val updated = _contactList.value + pubkey
|
|
||||||
nostr.profiles.setContactList(updated.toList())
|
|
||||||
_contactList.update { it + pubkey }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
|
||||||
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) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.profiles.searchByAddress(query))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
onResult(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.profiles.searchByNostr(query))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
onResult(emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.profiles.verifyActivity(pubkey))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
onResult(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.profiles.verifyContact(pubkey))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
onResult(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.profiles.mutualContacts(pubkey))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
onResult(emptySet())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
fun checkRelayList() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
|
||||||
|
|
||||||
val relays = withTimeoutOrNull(10.seconds) {
|
|
||||||
var result = nostr.relays.getMsgRelays(currentUser)
|
|
||||||
while (result.isEmpty()) {
|
|
||||||
delay(500.milliseconds)
|
|
||||||
result = nostr.relays.getMsgRelays(currentUser)
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} ?: emptyList()
|
|
||||||
|
|
||||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refetchMsgRelays() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
|
|
||||||
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
|
||||||
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useDefaultMsgRelayList() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
|
||||||
nostr.relays.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadCurrentUserRelayList() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
|
|
||||||
} 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) {
|
|
||||||
viewModelScope.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) {
|
|
||||||
viewModelScope.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) {
|
|
||||||
viewModelScope.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() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
|
||||||
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun currentUserMsgRelayListInternal(): 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) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayListInternal().toMutableSet()
|
|
||||||
relays.add(relayUrl)
|
|
||||||
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeMsgRelay(relay: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayListInternal().toMutableSet()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
|
|
||||||
nostr.relays.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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,
|
||||||
|
val screening: Boolean,
|
||||||
|
private val 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)
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
chatRepository.chatRoomConnect(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeNewEvents() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.newEvents.collect { event ->
|
||||||
|
if (event.roomId() == id) {
|
||||||
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
|
messages.add(0, event)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newOtherMessages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(text: String) {
|
||||||
|
chatRepository.sendMessage(id, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFileMessage(file: ByteArray?, type: String?) {
|
||||||
|
chatRepository.sendFileMessage(id, file, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
return chatRepository.isMessageSent(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,254 +1,41 @@
|
|||||||
package su.reya.coop.viewmodel
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.ChatState
|
||||||
import su.reya.coop.roomId
|
|
||||||
import su.reya.coop.viewmodel.createErrorHost
|
|
||||||
import su.reya.coop.viewmodel.ErrorHost
|
|
||||||
|
|
||||||
data class ChatState(
|
|
||||||
val rooms: Map<Long, Room> = emptyMap(),
|
|
||||||
val isPartialProcessedGiftWrap: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
private val nostr: Nostr,
|
private val repository: ChatRepository,
|
||||||
private val mediaRepository: MediaRepository,
|
) : ViewModel(), ErrorHost by repository {
|
||||||
) : ViewModel(), ErrorHost by createErrorHost() {
|
val state: StateFlow<ChatState> = repository.state
|
||||||
private val _state = MutableStateFlow(ChatState())
|
val newEvents: SharedFlow<UnsignedEvent> = repository.newEvents
|
||||||
val state = _state.stateIn(
|
val chatRooms: StateFlow<List<Room>> = repository.chatRooms
|
||||||
viewModelScope,
|
val isSyncing: StateFlow<Boolean> = repository.isSyncing
|
||||||
SharingStarted.WhileSubscribed(5000),
|
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
|
||||||
ChatState()
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
|
||||||
replay = 0,
|
fun getChatRoom(id: Long): Room? = repository.getChatRoom(id)
|
||||||
extraBufferCapacity = 100,
|
fun refreshChatRooms() = repository.refreshChatRooms()
|
||||||
onBufferOverflow = BufferOverflow.SUSPEND
|
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) =
|
||||||
)
|
repository.loadChatRoomMessages(roomId, onResult)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
fun chatRoomConnect(roomId: Long) = repository.chatRoomConnect(roomId)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) =
|
||||||
|
repository.sendMessage(roomId, message, replies)
|
||||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
|
|
||||||
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
// Observe message sync progress
|
|
||||||
launch {
|
|
||||||
nostr.messages.messageSyncState.collect { syncState ->
|
|
||||||
// When at least some messages are processed, allow UI to show the list
|
|
||||||
if (syncState.processedCount > 0 || !syncState.isSyncing) {
|
|
||||||
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh UI every 100 messages OR when sync is fully done
|
|
||||||
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
|
|
||||||
refreshChatRooms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe new messages
|
|
||||||
launch {
|
|
||||||
nostr.newEvents.collect { event ->
|
|
||||||
val roomId = event.roomId()
|
|
||||||
val existingRoom = _state.value.rooms[roomId]
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
|
||||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
|
||||||
val newRoom = Room.new(event, currentUser)
|
|
||||||
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
|
||||||
} else {
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEvents.tryEmit(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
|
||||||
try {
|
|
||||||
if (to.isEmpty()) {
|
|
||||||
throw IllegalArgumentException("At least one recipient is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current user
|
|
||||||
val currentUser = nostr.signer.publicKeyFlow.value
|
|
||||||
?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Construct the rumor event
|
|
||||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
|
||||||
.finalizeUnsigned(currentUser)
|
|
||||||
|
|
||||||
// Check if the room already exists
|
|
||||||
val id = rumor.roomId()
|
|
||||||
val existingRoom = _state.value.rooms[id]
|
|
||||||
|
|
||||||
// If the room already exists, return its ID
|
|
||||||
if (existingRoom != null) {
|
|
||||||
return existingRoom.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a room from the rumor event
|
|
||||||
val room = Room.new(rumor, currentUser)
|
|
||||||
|
|
||||||
// Update the chat rooms state
|
|
||||||
_state.update { it.copy(rooms = it.rooms + (room.id to room)) }
|
|
||||||
|
|
||||||
return room.id
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room? {
|
|
||||||
return _state.value.rooms[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
|
||||||
_state.update { currentState ->
|
|
||||||
val newMap = currentState.rooms.toMutableMap()
|
|
||||||
rooms.forEach { room -> newMap[room.id] = room }
|
|
||||||
currentState.copy(rooms = newMap)
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
onResult(nostr.messages.getChatRoomMessages(roomId))
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
val members = room.members
|
|
||||||
|
|
||||||
nostr.messages.chatRoomConnect(members.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
showError("Message cannot be empty")
|
|
||||||
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.tryEmit(event) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendFileMessage(
|
fun sendFileMessage(
|
||||||
roomId: Long,
|
roomId: Long,
|
||||||
file: ByteArray?,
|
file: ByteArray?,
|
||||||
contentType: String? = "image/jpeg",
|
contentType: String? = "image/jpeg",
|
||||||
replies: List<EventId> = emptyList()
|
replies: List<EventId> = emptyList()
|
||||||
) {
|
) = repository.sendFileMessage(roomId, file, contentType, replies)
|
||||||
if (file == null) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
fun isMessageSent(id: EventId): Boolean = repository.isMessageSent(id)
|
||||||
try {
|
fun resetInternalState() = repository.resetInternalState()
|
||||||
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
|
||||||
if (uri != null) sendMessage(roomId, uri, replies)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("File upload failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
|
||||||
val giftWrapId = nostr.messages.rumorMap[id]
|
|
||||||
|
|
||||||
if (giftWrapId != null) {
|
|
||||||
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
|
||||||
return isSent
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
|
||||||
_state.update { currentState ->
|
|
||||||
val room = currentState.rooms[roomId] ?: return@update currentState
|
|
||||||
val updatedRoom = room.copy(
|
|
||||||
lastMessage = newMessage.content(),
|
|
||||||
createdAt = newMessage.createdAt()
|
|
||||||
)
|
|
||||||
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resetInternalState() {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
rooms = emptyMap(),
|
|
||||||
isPartialProcessedGiftWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,10 +96,6 @@ class ProfileCache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a [StateFlow] for the profile of the given [pubkey].
|
|
||||||
* Triggers a metadata fetch if the profile is not yet cached.
|
|
||||||
*/
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||||
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
if (flow.value == null) requestMetadata(pubkey)
|
if (flow.value == null) requestMetadata(pubkey)
|
||||||
|
|||||||
Reference in New Issue
Block a user