From 410615bd8910a7ddf834d063120f013ddb91347a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 13 Jul 2026 01:04:15 +0000 Subject: [PATCH] chore: clean up codebase (#42) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/42 --- .../androidMain/kotlin/su/reya/coop/App.kt | 103 ++- .../kotlin/su/reya/coop/MainActivity.kt | 51 +- .../su/reya/coop/screens/ContactListScreen.kt | 35 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 43 +- .../su/reya/coop/screens/ImportScreen.kt | 11 +- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 7 +- .../su/reya/coop/screens/NewChatScreen.kt | 23 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 9 +- .../su/reya/coop/screens/OnboardingScreen.kt | 14 +- .../su/reya/coop/screens/ProfileScreen.kt | 14 +- .../su/reya/coop/screens/RelayScreen.kt | 28 +- .../su/reya/coop/screens/RequestListScreen.kt | 12 +- .../reya/coop/screens/UpdateProfileScreen.kt | 12 +- .../reya/coop/screens/chat/ChatComponents.kt | 18 +- .../su/reya/coop/screens/chat/ChatScreen.kt | 74 +-- .../commonMain/kotlin/su/reya/coop/Room.kt | 39 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 12 - .../su/reya/coop/nostr/ProfileManager.kt | 9 +- .../kotlin/su/reya/coop/nostr/RelayManager.kt | 22 +- .../reya/coop/repository/AccountRepository.kt | 597 ++++++++++++++++++ .../su/reya/coop/repository/ChatRepository.kt | 253 ++++++++ .../reya/coop/viewmodel/AccountViewModel.kt | 73 +++ .../coop/viewmodel/ChatScreenViewModel.kt | 72 +++ .../su/reya/coop/viewmodel/ChatViewModel.kt | 256 +------- .../{BaseViewModel.kt => ErrorHost.kt} | 19 +- .../su/reya/coop/viewmodel/NostrViewModel.kt | 126 ---- .../su/reya/coop/viewmodel/ProfileCache.kt | 93 +++ .../viewmodel/account/AccountAuthDelegate.kt | 252 -------- .../account/AccountContactDelegate.kt | 137 ---- .../account/AccountProfileDelegate.kt | 81 --- .../viewmodel/account/AccountRelayDelegate.kt | 185 ------ .../viewmodel/account/AccountViewModel.kt | 146 ----- 32 files changed, 1360 insertions(+), 1466 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt rename shared/src/commonMain/kotlin/su/reya/coop/viewmodel/{BaseViewModel.kt => ErrorHost.kt} (51%) delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index b4787cf..c5ae4df 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -26,7 +26,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey @@ -35,6 +38,8 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay 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.HomeScreen import su.reya.coop.screens.ImportScreen @@ -48,23 +53,15 @@ import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.chat.ChatScreen +import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatViewModel -import su.reya.coop.viewmodel.NostrViewModel -import su.reya.coop.viewmodel.account.AccountViewModel +import su.reya.coop.viewmodel.ProfileCache -val LocalNostrViewModel = staticCompositionLocalOf { - error("No NostrViewModel provided") +val LocalProfileCache = staticCompositionLocalOf { + error("No ProfileCache provided") } -val LocalChatViewModel = staticCompositionLocalOf { - error("No ChatViewModel provided") -} - -val LocalAccountViewModel = staticCompositionLocalOf { - error("No AccountViewModel provided") -} - - val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -80,10 +77,33 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App( - nostrViewModel: NostrViewModel, - chatViewModel: ChatViewModel, - accountViewModel: AccountViewModel, + profileCache: ProfileCache, + accountRepository: AccountRepository, + chatRepository: ChatRepository, ) { + val viewModelFactory = remember { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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 activity = context as? ComponentActivity val backStack = rememberNavBackStack(Screen.Home) @@ -130,7 +150,7 @@ fun App( } } launch { - nostrViewModel.errorEvents.collect { message -> + profileCache.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -141,7 +161,7 @@ fun App( fun handleIntent(intent: Intent) { val screen = Screen.fromIntent(intent) // Prevent pushing the same screen - if (screen != null && backStack.lastOrNull() != screen) { + if ((screen != null) && (backStack.lastOrNull() != screen)) { navigator.navigate(screen) } } @@ -174,9 +194,7 @@ fun App( motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalNostrViewModel provides nostrViewModel, - LocalChatViewModel provides chatViewModel, - LocalAccountViewModel provides accountViewModel, + LocalProfileCache provides profileCache, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, @@ -197,43 +215,62 @@ fun App( ), entryProvider = entryProvider { entry { - HomeScreen() + HomeScreen(accountViewModel, chatViewModel) } entry { - RequestListScreen() + RequestListScreen(chatViewModel) } entry { - OnboardingScreen() + OnboardingScreen(accountViewModel) } entry { - ImportScreen() + ImportScreen(accountViewModel) } entry { - NewIdentityScreen() + NewIdentityScreen(accountViewModel) } entry { key -> - ChatScreen(id = key.id, screening = key.screening) + val factory = remember(key) { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return ChatScreenViewModel( + key.id, + key.screening, + accountRepository, + chatRepository + ) as T + } + } + } + ChatScreen( + viewModel( + key = key.id.toString(), + factory = factory + ), + accountViewModel + ) } entry { - NewChatScreen() + NewChatScreen(accountViewModel, chatViewModel) } entry { key -> - ProfileScreen(pubkey = key.pubkey) + ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel) } entry { - UpdateProfileScreen() + UpdateProfileScreen(accountViewModel) } entry { ScanScreen() } entry { - MyQrScreen() + MyQrScreen(accountViewModel) } entry { - ContactListScreen() + ContactListScreen(accountViewModel, chatViewModel) } entry { - RelayScreen() + RelayScreen(accountViewModel) } } ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 3c73159..a50c8c2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -8,15 +8,13 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.MainScope 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.viewmodel.ChatViewModel -import su.reya.coop.viewmodel.NostrViewModel -import su.reya.coop.viewmodel.account.AccountViewModel +import su.reya.coop.viewmodel.ProfileCache import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -24,31 +22,20 @@ class MainActivity : ComponentActivity() { val externalSignerLauncher = ExternalSignerLauncher() } - private val factory by lazy { - object : ViewModelProvider.Factory { - private val storage = AppStore(this@MainActivity) - private val mediaRepository = MediaRepository() - private val nostrViewModel = NostrViewModel(NostrManager.instance) - private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) - private val androidSigner = - AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val accountViewModel = - AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) + private val profileCache by lazy { ProfileCache(NostrManager.instance) } + private val scope = MainScope() - override fun create(modelClass: Class): T { - return when { - modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel - modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel - modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel - else -> throw IllegalArgumentException("Unknown ViewModel class") - } as T - } - } + 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 nostrViewModel: NostrViewModel by viewModels { factory } - private val chatViewModel: ChatViewModel by viewModels { factory } - private val accountViewModel: AccountViewModel by viewModels { factory } + private val chatRepository by lazy { + val mediaRepository = MediaRepository() + ChatRepository(NostrManager.instance, mediaRepository, scope) + } override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -87,14 +74,14 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - accountViewModel.state.value.signerRequired == null + accountRepository.state.value.signerRequired == null } setContent { App( - nostrViewModel = nostrViewModel, - chatViewModel = chatViewModel, - accountViewModel = accountViewModel, + profileCache = profileCache, + accountRepository = accountRepository, + chatRepository = chatRepository, ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index e20f1df..175285a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -54,28 +53,26 @@ import coop.composeapp.generated.resources.ic_check import coop.composeapp.generated.resources.ic_close import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.short +import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ContactListScreen() { +fun ContactListScreen( + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel +) { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() var openAddContactDialog by remember { mutableStateOf(false) } var contactToDelete by remember { mutableStateOf(null) } @@ -192,7 +189,10 @@ fun ContactListScreen() { ) if (openAddContactDialog) { - AddContactDialog(onDismissRequest = { openAddContactDialog = false }) + AddContactDialog( + accountViewModel = accountViewModel, + onDismissRequest = { openAddContactDialog = false } + ) } if (contactToDelete != null) { @@ -221,16 +221,15 @@ fun ContactListScreen() { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun AddContactDialog(onDismissRequest: () -> Unit) { +fun AddContactDialog( + accountViewModel: AccountViewModel, + onDismissRequest: () -> Unit, +) { val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current - val accountViewModel = LocalAccountViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -326,8 +325,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit, ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle(initialValue = null) SegmentedListItem( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index bc35c80..7b38202 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -89,31 +89,32 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.RoomKind +import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.ago -import su.reya.coop.rememberUiState import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.uiStateFlow +import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen() { +fun HomeScreen( + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel +) { val context = LocalContext.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val nostrViewModel = LocalNostrViewModel.current - val chatViewModel = LocalChatViewModel.current - val accountViewModel = LocalAccountViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) @@ -459,7 +460,11 @@ fun HomeScreen() { } } Spacer(modifier = Modifier.size(16.dp)) - BottomMenuList(onDismiss = dismissAndRun) + BottomMenuList( + onDismiss = dismissAndRun, + accountViewModel = accountViewModel, + chatViewModel = chatViewModel + ) } } } @@ -609,14 +614,16 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current + val profileCache = LocalProfileCache.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) - val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel) + val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) + .collectAsStateWithLifecycle(RoomUiState()) + val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) + .collectAsStateWithLifecycle(RoomUiState()) val supportingText = when { total == 1 -> { @@ -687,8 +694,9 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val nostrViewModel = LocalNostrViewModel.current - val roomState by room.rememberUiState(nostrViewModel) + val profileCache = LocalProfileCache.current + val roomState by room.uiStateFlow(profileCache) + .collectAsStateWithLifecycle(RoomUiState()) ListItem( modifier = Modifier.clickable(onClick = onClick), @@ -731,12 +739,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BottomMenuList( - onDismiss: (suspend () -> Unit) -> Unit + onDismiss: (suspend () -> Unit) -> Unit, + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel, ) { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current - val chatViewModel = LocalChatViewModel.current - val accountViewModel = LocalAccountViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 9960a9f..70052d3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -50,23 +50,20 @@ import coop.composeapp.generated.resources.ic_scanner import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen +import su.reya.coop.viewmodel.AccountViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ImportScreen() { +fun ImportScreen(viewModel: AccountViewModel) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val accountViewModel = LocalAccountViewModel.current - - val accountState by accountViewModel.state.collectAsStateWithLifecycle() - + val accountState by viewModel.state.collectAsStateWithLifecycle() var secret by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var requirePassword by remember { mutableStateOf(false) } @@ -250,7 +247,7 @@ fun ImportScreen() { Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - accountViewModel.importIdentity(secret, password) + viewModel.importIdentity(secret, password) }, modifier = Modifier .fillMaxWidth() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index 81e7a99..193b05c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -21,16 +21,15 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.viewmodel.AccountViewModel @Composable -fun MyQrScreen() { +fun MyQrScreen(viewModel: AccountViewModel) { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val accountViewModel = LocalAccountViewModel.current - val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() + val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index cd0242d..a8d1076 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -55,27 +55,26 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.short +import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChatViewModel import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun NewChatScreen() { +fun NewChatScreen( + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel +) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val nostrViewModel = LocalNostrViewModel.current - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } @@ -294,8 +293,8 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsState(initial = null) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { @@ -376,8 +375,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val nostrViewModel = LocalNostrViewModel.current - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileCache = LocalProfileCache.current + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 54d7d36..0bd41a1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -3,16 +3,15 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor +import su.reya.coop.viewmodel.AccountViewModel @Composable -fun NewIdentityScreen() { - val accountViewModel = LocalAccountViewModel.current +fun NewIdentityScreen(viewModel: AccountViewModel) { val navigator = LocalNavigator.current - val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val accountState by viewModel.state.collectAsStateWithLifecycle() ProfileEditor( title = "Create a new identity", @@ -20,7 +19,7 @@ fun NewIdentityScreen() { isBusy = accountState.isImporting, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> - accountViewModel.createIdentity(name, bio, bytes, type) + viewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home) } ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index d886f41..0726066 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -43,26 +43,24 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch -import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.viewmodel.AccountViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun OnboardingScreen() { +fun OnboardingScreen(viewModel: AccountViewModel) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val accountViewModel = LocalAccountViewModel.current - - val accountState by accountViewModel.state.collectAsStateWithLifecycle() + val accountState by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() // Navigate to Home on successful external signer connection @@ -176,10 +174,10 @@ fun OnboardingScreen() { Spacer(modifier = Modifier.size(8.dp)) FilledTonalButton( onClick = { - if (accountViewModel.isExternalSignerAvailable()) { + if (viewModel.isExternalSignerAvailable()) { // Connect to the external signer // TODO: show all available signers? - accountViewModel.connectExternalSigner() + viewModel.connectExternalSigner() } else { scope.launch { val result = snackbarHostState.showSnackbar( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 35cd311..736c780 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,28 +44,30 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.short +import su.reya.coop.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ProfileScreen(pubkey: String) { +fun ProfileScreen( + pubkey: String, + chatViewModel: ChatViewModel +) { val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current - val chatViewModel = LocalChatViewModel.current + val profileCache = LocalProfileCache.current val scope = rememberCoroutineScope() - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() val metadata = profile?.metadata?.asRecord() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 6689755..036008a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -67,16 +67,13 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.viewmodel.AccountViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun RelayScreen() { +fun RelayScreen(viewModel: AccountViewModel) { val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current - val accountViewModel = LocalAccountViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -99,12 +96,12 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - accountViewModel.loadCurrentUserRelayList() - accountViewModel.loadCurrentUserMsgRelayList() + viewModel.loadCurrentUserRelayList() + viewModel.loadCurrentUserMsgRelayList() } - val loadedRelayList by accountViewModel.currentUserRelayList.collectAsStateWithLifecycle() - val loadedMsgRelayList by accountViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle() + val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle() + val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle() LaunchedEffect(loadedRelayList) { if (loadedRelayList.isNotEmpty()) { @@ -316,6 +313,7 @@ fun RelayScreen() { if (openAddRelayDialog) { AddRelayDialog( + viewModel = viewModel, onDismissRequest = { openAddRelayDialog = false }, onMsgRelayAdded = { newRelay -> msgRelayList.add(RelayUrl.parse(newRelay)) @@ -341,7 +339,7 @@ fun RelayScreen() { relayToDelete = null return@TextButton } - accountViewModel.removeMsgRelay(relayToDelete!!) + viewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } @@ -361,14 +359,12 @@ fun RelayScreen() { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AddRelayDialog( + viewModel: AccountViewModel, onDismissRequest: () -> Unit, onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val accountViewModel = LocalAccountViewModel.current val snackbarHostState = LocalSnackbarHostState.current - - val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } var relayAddress by remember { mutableStateOf("") } @@ -416,17 +412,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - accountViewModel.addMsgRelay(relayAddress) + viewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - accountViewModel.addInboxRelay(relayAddress) + viewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - accountViewModel.addOutboxRelay(relayAddress) + viewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index 3951605..5f57c1d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,26 +35,23 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back -import kotlinx.coroutines.launch -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState import su.reya.coop.RoomKind import su.reya.coop.Screen +import su.reya.coop.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun RequestListScreen() { +fun RequestListScreen(viewModel: ChatViewModel) { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val chatViewModel = LocalChatViewModel.current - val scope = rememberCoroutineScope() val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() var isRefreshing by remember { mutableStateOf(false) } - val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -102,7 +98,7 @@ fun RequestListScreen() { state = pullToRefreshState, onRefresh = { isRefreshing = true - chatViewModel.refreshChatRooms() + viewModel.refreshChatRooms() isRefreshing = false }, indicator = { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index acdd26a..d1f54bc 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -3,18 +3,16 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator import su.reya.coop.shared.ProfileEditor +import su.reya.coop.viewmodel.AccountViewModel @Composable -fun UpdateProfileScreen() { - val accountViewModel = LocalAccountViewModel.current +fun UpdateProfileScreen(viewModel: AccountViewModel) { val navigator = LocalNavigator.current - - val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() + val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle() val profile = currentUser?.metadata?.asRecord() - val isUpdatingProfile by accountViewModel.isUpdatingProfile.collectAsStateWithLifecycle() + val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle() ProfileEditor( title = "Update profile", @@ -25,7 +23,7 @@ fun UpdateProfileScreen() { isBusy = isUpdatingProfile, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> - accountViewModel.updateProfile(name, bio, bytes, type) + viewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt index 39ac829..59791fd 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -31,36 +30,35 @@ import coop.composeapp.generated.resources.ic_check_circle import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.Room import su.reya.coop.humanReadable import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.short +import su.reya.coop.viewmodel.AccountViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ScreenerCard(room: Room) { +fun ScreenerCard(viewModel: AccountViewModel, room: Room) { val pubkey = room.members.firstOrNull() ?: return - val nostrViewModel = LocalNostrViewModel.current - val accountViewModel = LocalAccountViewModel.current + val profileCache = LocalProfileCache.current var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { // Check contact - accountViewModel.verifyContact(pubkey) { isContact = it } + viewModel.verifyContact(pubkey) { isContact = it } // Get mutual contacts - accountViewModel.mutualContacts(pubkey) { mutualContacts = it } + viewModel.mutualContacts(pubkey) { mutualContacts = it } // Get the last activity - accountViewModel.verifyActivity(pubkey) { lastActivity = it } + viewModel.verifyActivity(pubkey) { lastActivity = it } } Column( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index a8f951a..3c92a6b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -45,8 +45,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -64,40 +62,35 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource -import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalProfileCache import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.formatAsGroupHeader -import su.reya.coop.rememberUiState -import su.reya.coop.roomId import su.reya.coop.shared.Avatar +import su.reya.coop.uiStateFlow +import su.reya.coop.viewmodel.AccountViewModel +import su.reya.coop.viewmodel.ChatScreenViewModel @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatScreen( - id: Long, - screening: Boolean = false, + viewModel: ChatScreenViewModel, + accountViewModel: AccountViewModel, coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current - val chatViewModel = LocalChatViewModel.current + val profileCache = LocalProfileCache.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - // Get current user - val accountViewModel = LocalAccountViewModel.current - val currentUser by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() - - // Get chat room by ID - val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() + val id = viewModel.id + val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() + val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -115,13 +108,15 @@ fun ChatScreen( return } - val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) - var text by remember { mutableStateOf("") } - var loading by remember { mutableStateOf(true) } - var newOtherMessages by remember { mutableIntStateOf(0) } - var requireScreening by remember { mutableStateOf(screening) } + val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) + .collectAsStateWithLifecycle(RoomUiState()) - val messages = remember { mutableStateListOf() } + var text by remember { mutableStateOf("") } + val loading = viewModel.loading + val newOtherMessages = viewModel.newOtherMessages + val requireScreening = viewModel.requireScreening + + val messages = viewModel.messages val groupedMessages = remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } } @@ -136,7 +131,7 @@ fun ChatScreen( val type = context.contentResolver.getType(uri) // Send message (handles errors internally via ViewModel) - chatViewModel.sendFileMessage(id, file, type) + viewModel.sendFileMessage(file, type) } } @@ -155,29 +150,6 @@ fun ChatScreen( } 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) { @@ -254,7 +226,7 @@ fun ChatScreen( .padding(bottom = innerPadding.calculateBottomPadding()) ) { if (requireScreening) { - room?.let { ScreenerCard(it) } + room?.let { ScreenerCard(accountViewModel, it) } } val mineColor = MaterialTheme.colorScheme.onPrimaryContainer @@ -338,7 +310,7 @@ fun ChatScreen( ) } FilledTonalButton( - onClick = { requireScreening = false }, + onClick = { viewModel.requireScreening = false }, modifier = Modifier .weight(1f) .size(ButtonDefaults.MediumContainerHeight) @@ -356,7 +328,7 @@ fun ChatScreen( value = text, onValueChange = { text = it }, onSend = { - chatViewModel.sendMessage(id, text) + viewModel.sendMessage(text) text = "" }, onUpload = { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index d06109b..65d1a98 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,9 +1,5 @@ package su.reya.coop -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.remember -import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -15,7 +11,7 @@ import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.viewmodel.NostrViewModel +import su.reya.coop.viewmodel.ProfileCache import kotlin.time.Clock import kotlin.time.Instant @@ -66,22 +62,6 @@ data class Room( } } - fun setKind(kind: RoomKind): Room { - return this.copy(kind = kind) - } - - fun setCreatedAt(createdAt: Timestamp): Room { - return this.copy(createdAt = createdAt) - } - - fun setSubject(subject: String?): Room { - return this.copy(subject = subject) - } - - fun setLastMessage(message: String?): Room { - return this.copy(lastMessage = message) - } - fun isGroup(): Boolean = members.size > 1 } @@ -92,7 +72,7 @@ data class RoomUiState( ) fun Room.uiStateFlow( - nostrViewModel: NostrViewModel, + profileCache: ProfileCache, currentUser: PublicKey? = null ): Flow { val displayMembers = if (isGroup()) members.take(2) else members.take(1) @@ -101,7 +81,7 @@ fun Room.uiStateFlow( return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup())) } - return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> + return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles -> val names = profiles.mapIndexed { i, profile -> profile?.name?.sanitizeName() ?: displayMembers[i].short() } @@ -127,19 +107,6 @@ fun Room.uiStateFlow( } } -@Composable -fun Room.rememberUiState( - viewModel: NostrViewModel, - currentUser: PublicKey? = null -): State { - return remember(this, currentUser) { - uiStateFlow( - viewModel, - currentUser - ) - }.collectAsStateWithLifecycle(RoomUiState()) -} - fun UnsignedEvent.roomId(): Long { // Collect the author's public key and all public keys from tags val pubkeys: MutableList = mutableListOf() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 27d4031..e7617d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -37,18 +37,6 @@ import kotlin.time.Duration.Companion.seconds object NostrManager { val instance = Nostr() - - val BOOTSTRAP_RELAYS = listOf( - "wss://relay.primal.net", - "wss://relay.ditto.pub", - "wss://user.kindpag.es", - ) - - val INDEXER_RELAY = listOf( - "wss://indexer.coracle.social", - ) - - val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY } class Nostr( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index 7579420..f228757 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -32,6 +32,7 @@ import kotlin.time.Duration class ProfileManager(private val nostr: Nostr) { private val client: Client? get() = nostr.client private val signer: UniversalSigner get() = nostr.signer + private val httpClient by lazy { HttpClient() } private val _metadataUpdates = MutableSharedFlow>( @@ -88,7 +89,7 @@ class ProfileManager(private val nostr: Nostr) { try { val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) - val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } + val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } client?.sync(filter, relays) } catch (e: Exception) { @@ -226,7 +227,7 @@ class ProfileManager(private val nostr: Nostr) { // Construct request target val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + RelayManager.BOOTSTRAP_RELAYS.forEach { relay -> target[RelayUrl.parse(relay)] = listOf(filter) } @@ -265,7 +266,7 @@ class ProfileManager(private val nostr: Nostr) { suspend fun searchByAddress(query: String): PublicKey { try { val address = Nip05Address.parse(query) - val profile = profileFromAddress(HttpClient(), address) + val profile = profileFromAddress(httpClient, address) return profile.publicKey() } catch (e: Exception) { @@ -312,7 +313,7 @@ class ProfileManager(private val nostr: Nostr) { try { val filter = Filter().author(pubkey).limit(3u) val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + RelayManager.BOOTSTRAP_RELAYS.forEach { relay -> target[RelayUrl.parse(relay)] = listOf(filter) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index 6c7d57f..12482bd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration class RelayManager(private val nostr: Nostr) { + companion object { + val BOOTSTRAP_RELAYS = listOf( + "wss://relay.primal.net", + "wss://relay.ditto.pub", + "wss://user.kindpag.es", + ) + + val INDEXER_RELAY = listOf( + "wss://indexer.coracle.social", + ) + + val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY + } + private val client: Client? get() = nostr.client private val signer: UniversalSigner get() = nostr.signer suspend fun connectBootstrapRelays() { - NostrManager.BOOTSTRAP_RELAYS.forEach { url -> + BOOTSTRAP_RELAYS.forEach { url -> client?.addRelay(RelayUrl.parse(url)) } - NostrManager.INDEXER_RELAY.forEach { url -> + INDEXER_RELAY.forEach { url -> client?.addRelay( url = RelayUrl.parse(url), capabilities = RelayCapabilities.gossip() @@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) { } suspend fun reconnect() { - NostrManager.ALL_RELAYS.forEach { url -> + ALL_RELAYS.forEach { url -> try { client?.relay(RelayUrl.parse(url)).let { relay -> if (relay != null) { @@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) { } suspend fun disconnect() { - NostrManager.ALL_RELAYS.forEach { url -> + ALL_RELAYS.forEach { url -> try { client?.disconnectRelay(RelayUrl.parse(url)) } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt new file mode 100644 index 0000000..e7562c7 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt @@ -0,0 +1,597 @@ +package su.reya.coop.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EncryptedSecretKey +import rust.nostr.sdk.Keys +import rust.nostr.sdk.NostrConnect +import rust.nostr.sdk.NostrConnectUri +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Timestamp +import su.reya.coop.AppStorage +import su.reya.coop.Profile +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.viewmodel.ErrorHost +import su.reya.coop.viewmodel.createErrorHost +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +data class AccountState( + val signerRequired: Boolean? = null, + val isNotificationBannerDismissed: Boolean = false, + val isImporting: Boolean = false, + val importError: String? = null, +) + +class AccountRepository( + private val nostr: Nostr, + private val storage: AppStorage, + private val mediaRepository: MediaRepository, + private val scope: CoroutineScope, + private val externalSignerHandler: ExternalSignerHandler? = null, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : ErrorHost by createErrorHost() { + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } + + private val _state = MutableStateFlow(AccountState()) + val state: StateFlow = _state.asStateFlow() + + private val _isUpdatingProfile = MutableStateFlow(false) + val isUpdatingProfile: StateFlow = _isUpdatingProfile.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile: StateFlow = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null) + } + .stateIn(scope, SharingStarted.WhileSubscribed(5000), null) + + private val _contactList = MutableStateFlow>(emptySet()) + val contactList: StateFlow> = _contactList.asStateFlow() + + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow() + + private val _userRelayList = MutableStateFlow>(emptyMap()) + val userRelayList: StateFlow> = _userRelayList.asStateFlow() + + private val _userMsgRelayList = MutableStateFlow>(emptyList()) + val userMsgRelayList: StateFlow> = _userMsgRelayList.asStateFlow() + + init { + checkNotificationBannerDismissedStatus() + login() + observeSignerState() + observeContactList() + } + + private fun observeSignerState() { + scope.launch { + nostr.signer.publicKeyFlow + .filterNotNull() + .distinctUntilChanged() + .collectLatest { pubkey -> + // Refresh metadata + nostr.profiles.getUserMetadata() + + // Verify messaging relays exist (wait up to 10s for initial fetch) + val relays = withTimeoutOrNull(10.seconds) { + var list = nostr.relays.getMsgRelays(pubkey) + while (list.isEmpty()) { + delay(500.milliseconds) + list = nostr.relays.getMsgRelays(pubkey) + } + list + } ?: emptyList() + + // Automatically update the warning state + _isRelayListEmpty.value = relays.isEmpty() + } + } + } + + private fun checkNotificationBannerDismissedStatus() { + scope.launch { + val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" + _state.update { it.copy(isNotificationBannerDismissed = dismissed) } + } + } + + private fun login() { + scope.launch { + try { + val secret = withTimeoutOrNull(5.seconds) { + storage.getSecret(KEY_USER_SIGNER) + } + + if (secret == null) { + _state.update { it.copy(signerRequired = true) } + return@launch + } + + runCatching { + val (signer, _) = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _state.update { it.copy(signerRequired = false) } + }.onFailure { e -> + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } catch (e: Exception) { + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun logout(onLogout: () -> Unit = {}) { + scope.launch { + try { + nostr.signer.switch(Keys.generate()) + nostr.prune() + } catch (e: Exception) { + showError("Logout encountered an error: ${e.message}") + } finally { + storage.clear(KEY_USER_SIGNER) + storage.clear(KEY_BANNER_DISMISSED) + onLogout() + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun dismissNotificationBanner() { + scope.launch { + storage.set(KEY_BANNER_DISMISSED, "true") + _state.update { it.copy(isNotificationBannerDismissed = true) } + } + } + + private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { + val secret = storage.getSecret(KEY_APP_KEYS) + if (secret != null) return@withContext Keys.parse(secret) + val keys = Keys.generate() + storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) + keys + } + + private suspend fun createSigner( + secret: String, + password: String? = null + ): Pair = withContext(defaultDispatcher) { + when { + secret.startsWith("nsec1") -> Keys.parse(secret) to null + + secret.startsWith("ncryptsec1") -> { + if (password == null) throw IllegalArgumentException("Password is required") + val enc = EncryptedSecretKey.fromBech32(secret) + val decrypted = enc.decrypt(password) + val keys = Keys(decrypted) + keys to keys.secretKey().toBech32() + } + + secret.startsWith("bunker://") -> { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = 50.seconds + NostrConnect(uri = bunker, appKeys, timeout, null) to null + } + + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler ?: throw IllegalStateException("Not available") + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) to null + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + + fun importIdentity(secret: String, password: String? = null) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val (signer, decryptedSecret) = createSigner(secret, password) + + nostr.setSigner(signer) + + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("Import failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun connectExternalSigner() { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val handler = + externalSignerHandler ?: throw IllegalStateException("Signer not available") + + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}" + + nostr.setSigner(signer) + + storage.setSecret(KEY_USER_SIGNER, uri) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("External signer connection failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") + } + + nostr.profiles.createIdentity( + keys = keys, + name = name, + bio = bio, + picture = avatarUrl + ) + + storage.setSecret(KEY_USER_SIGNER, secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("Identity creation failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + + @OptIn(ExperimentalCoroutinesApi::class) + private fun currentUserProfileFlow(pubkey: PublicKey) = merge( + flow { + nostr.waitUntilInitialized() + val cached = nostr.profiles.getAllCacheMetadata()[pubkey] + if (cached != null) emit(Profile(pubkey, cached)) + nostr.profiles.fetchMetadataBatch(listOf(pubkey)) + }, + nostr.profiles.metadataUpdates + .filter { (p, _) -> p == pubkey } + .map { (p, m) -> Profile(p, m) } + ) + + fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + scope.launch { + _isUpdatingProfile.value = true + try { + val avatarUrl = picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } + nostr.profiles.updateProfile(name, bio, avatarUrl) + _isUpdatingProfile.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + _isUpdatingProfile.value = false + } + } + } + + private fun observeContactList() { + scope.launch { + nostr.waitUntilInitialized() + nostr.profiles.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + } + + fun resetInternalState() { + _contactList.value = emptySet() + _isRelayListEmpty.value = false + } + + fun addContact(address: String) { + scope.launch { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + showError("Invalid contact address: ${e.message}") + return@launch + } + + if (pubkey in _contactList.value) return@launch + + try { + val updated = _contactList.value + pubkey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it + pubkey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeContact(publicKey: PublicKey) { + scope.launch { + if (publicKey !in _contactList.value) return@launch + + try { + val updated = _contactList.value - publicKey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it - publicKey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.searchByAddress(query)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(null) + } + } + } + + fun searchByNostr(query: String, onResult: (List) -> 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) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.mutualContacts(pubkey)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(emptySet()) + } + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + fun refetchMsgRelays() { + scope.launch { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch + val relays = nostr.relays.fetchMsgRelays(currentUser) + + if (relays.isNotEmpty()) dismissRelayWarning() + } + } + + fun useDefaultMsgRelayList() { + scope.launch { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserRelayList() { + scope.launch { + try { + val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _userRelayList.value = nostr.relays.getRelayList(user) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun currentUserRelayListInternal(): Map { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyMap() + } + } + + fun addInboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun addOutboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays.remove(relayUrl) + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserMsgRelayList() { + scope.launch { + try { + val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _userMsgRelayList.value = nostr.relays.getMsgRelays(user) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun userLatestMsgRelayList(): List { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyList() + } + } + + fun addMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = userLatestMsgRelayList().toMutableSet() + relays.add(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = userLatestMsgRelayList().toMutableSet() + relays.remove(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt new file mode 100644 index 0000000..5647b76 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -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 = 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( + 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): 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) -> 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 = 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 = 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, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt new file mode 100644 index 0000000..e25a53e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt @@ -0,0 +1,73 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Timestamp +import su.reya.coop.Profile +import su.reya.coop.repository.AccountRepository +import su.reya.coop.repository.AccountState + +class AccountViewModel( + private val repository: AccountRepository, +) : ViewModel(), ErrorHost by repository { + + val state: StateFlow = repository.state + val isUpdatingProfile: StateFlow = repository.isUpdatingProfile + val currentUserProfile: StateFlow = repository.currentUserProfile + val contactList: StateFlow> = repository.contactList + val isRelayListEmpty: StateFlow = repository.isRelayListEmpty + val userRelayList: StateFlow> = repository.userRelayList + val userMsgRelayList: StateFlow> = repository.userMsgRelayList + + fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout) + fun dismissNotificationBanner() = repository.dismissNotificationBanner() + fun isExternalSignerAvailable() = repository.isExternalSignerAvailable() + fun importIdentity(secret: String, password: String? = null) = + repository.importIdentity(secret, password) + + fun connectExternalSigner() = repository.connectExternalSigner() + fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) = repository.createIdentity(name, bio, picture, contentType) + + fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) = repository.updateProfile(name, bio, picture, contentType) + + fun resetInternalState() = repository.resetInternalState() + fun addContact(address: String) = repository.addContact(address) + fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey) + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) = + repository.searchByAddress(query, onResult) + + fun searchByNostr(query: String, onResult: (List) -> Unit) = + repository.searchByNostr(query, onResult) + + fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) = + repository.verifyActivity(pubkey, onResult) + + fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) = + repository.verifyContact(pubkey, onResult) + + fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) = + repository.mutualContacts(pubkey, onResult) + + fun dismissRelayWarning() = repository.dismissRelayWarning() + fun refetchMsgRelays() = repository.refetchMsgRelays() + fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList() + fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList() + fun addInboxRelay(relay: String) = repository.addInboxRelay(relay) + fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay) + fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList() + fun addMsgRelay(relay: String) = repository.addMsgRelay(relay) + fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt new file mode 100644 index 0000000..44b300f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -0,0 +1,72 @@ +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.UnsignedEvent +import su.reya.coop.Profile +import su.reya.coop.Room +import su.reya.coop.repository.AccountRepository +import su.reya.coop.repository.ChatRepository +import su.reya.coop.roomId + +class ChatScreenViewModel( + val id: Long, + screening: Boolean, + accountRepository: AccountRepository, + private val chatRepository: ChatRepository, +) : ViewModel(), ErrorHost by chatRepository { + val currentUser: StateFlow = accountRepository.currentUserProfile + val chatRooms: StateFlow> = chatRepository.chatRooms + + var loading by mutableStateOf(true) + var newOtherMessages by mutableIntStateOf(0) + var requireScreening by mutableStateOf(screening) + val messages = mutableStateListOf() + + 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) + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index 8ea4735..570ce07 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,251 +1,21 @@ package su.reya.coop.viewmodel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CancellationException -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 androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow 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.repository.MediaRepository -import su.reya.coop.roomId - -data class ChatState( - val rooms: Map = emptyMap(), - val isPartialProcessedGiftWrap: Boolean = false, -) +import su.reya.coop.repository.ChatRepository +import su.reya.coop.repository.ChatState class ChatViewModel( - private val nostr: Nostr, - private val mediaRepository: MediaRepository, -) : BaseViewModel() { - private val _state = MutableStateFlow(ChatState()) - val state = _state.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - ChatState() - ) + private val repository: ChatRepository, +) : ViewModel(), ErrorHost by repository { + val state: StateFlow = repository.state + val chatRooms: StateFlow> = repository.chatRooms + val isSyncing: StateFlow = repository.isSyncing + val isPartialProcessedGiftWrap: StateFlow = repository.isPartialProcessedGiftWrap - private val _newEvents = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 100, - onBufferOverflow = BufferOverflow.SUSPEND - ) - val newEvents = _newEvents.asSharedFlow() - - val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - 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): 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) -> 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 = 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( - roomId: Long, - file: ByteArray?, - contentType: String? = "image/jpeg", - replies: List = emptyList() - ) { - if (file == null) return - - viewModelScope.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, - ) - } - } + fun createChatRoom(to: List): Long = repository.createChatRoom(to) + fun refreshChatRooms() = repository.refreshChatRooms() + fun resetInternalState() = repository.resetInternalState() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt similarity index 51% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt index e4d17d4..a8fc6bf 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt @@ -1,19 +1,28 @@ package su.reya.coop.viewmodel -import androidx.lifecycle.ViewModel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -abstract class BaseViewModel : ViewModel() { +interface ErrorHost { + val errorEvents: SharedFlow + + fun showError(message: String) +} + +fun createErrorHost(): ErrorHost = ErrorHostImpl() + +private class ErrorHostImpl : ErrorHost { private val _errorEvents = MutableSharedFlow( replay = 0, extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.SUSPEND ) - val errorEvents = _errorEvents.asSharedFlow() - protected fun showError(message: String) { + override val errorEvents: SharedFlow = _errorEvents.asSharedFlow() + + override fun showError(message: String) { _errorEvents.tryEmit(message) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt deleted file mode 100644 index 8960ac9..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ /dev/null @@ -1,126 +0,0 @@ -package su.reya.coop.viewmodel - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.PublicKey -import su.reya.coop.Profile -import su.reya.coop.nostr.Nostr -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds - -class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { - private val profilesMutex = Mutex() - private val profiles = mutableMapOf>() - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() - - init { - // Launch continuous background observers - viewModelScope.launch { runObserver() } - viewModelScope.launch { runMetadataBatching() } - - // Automatically reconnect bootstrap relays - reconnect() - - // Get all local stored metadata - getCacheMetadata() - } - - private fun reconnect() { - viewModelScope.launch { - nostr.waitUntilInitialized() - nostr.reconnect() - } - } - - private suspend fun runObserver() = coroutineScope { - // Observe metadata updates - launch { - nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, Profile(pubkey, metadata)) - } - } - } - - private suspend fun runMetadataBatching() { - // Wait until the client is ready - nostr.waitUntilInitialized() - - val batch = mutableSetOf() - val timeout = 500L // 500ms timeout for batching - - while (true) { - // Get the first pubkey - val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) - - // Get current time - val lastFlushTime = Clock.System.now().toEpochMilliseconds() - - while (batch.isNotEmpty()) { - // Get the next pubkey - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - metadataRequestChannel.receive() - } - - // Only add the pubkey if it's not null - if (nextKey != null) batch.add(nextKey) - - // Get current time - val now = Clock.System.now().toEpochMilliseconds() - - // Check if the batch is full or timeout has passed - if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { - val keysToRequest = batch.toList() - batch.clear() - - nostr.profiles.fetchMetadataBatch(keysToRequest) - } - } - } - } - - private fun getCacheMetadata() { - viewModelScope.launch { - // Wait until the client is ready - nostr.waitUntilInitialized() - val cache = nostr.profiles.getAllCacheMetadata() - - profilesMutex.withLock { - cache.forEach { (pubkey, metadata) -> - val profile = Profile(pubkey, metadata) - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - seenPublicKeys.add(pubkey) - } - } - } - } - - private fun requestMetadata(pubkey: PublicKey) { - if (seenPublicKeys.add(pubkey)) { - metadataRequestChannel.trySend(pubkey) - } - } - - private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { - profilesMutex.withLock { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile - } - } - - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) requestMetadata(pubkey) - - return flow.asStateFlow() - } - -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt new file mode 100644 index 0000000..8e6a638 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt @@ -0,0 +1,93 @@ +package su.reya.coop.viewmodel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.PublicKey +import su.reya.coop.Profile +import su.reya.coop.nostr.Nostr +import kotlin.time.Duration.Companion.milliseconds + +class ProfileCache( + private val nostr: Nostr, +) : ErrorHost by createErrorHost() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val profilesMutex = Mutex() + private val profiles = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + init { + scope.launch { runObserver() } + scope.launch { runMetadataBatching() } + scope.launch { + nostr.waitUntilInitialized() + loadCacheMetadata() + } + } + + private suspend fun runObserver() = coroutineScope { + launch { + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> + updateMetadata(pubkey, Profile(pubkey, metadata)) + } + } + } + + private suspend fun runMetadataBatching() { + nostr.waitUntilInitialized() + + while (true) { + val firstKey = metadataRequestChannel.receive() + val batch = mutableSetOf(firstKey) + + while (batch.size < 10) { + val nextKey = + withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() } + ?: break + batch.add(nextKey) + } + + nostr.profiles.fetchMetadataBatch(batch.toList()) + } + } + + private suspend fun loadCacheMetadata() { + val cache = nostr.profiles.getAllCacheMetadata() + + profilesMutex.withLock { + cache.forEach { (pubkey, metadata) -> + val profile = Profile(pubkey, metadata) + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + seenPublicKeys.add(pubkey) + } + } + } + + private fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + metadataRequestChannel.trySend(pubkey) + } + } + + private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + } + } + + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) requestMetadata(pubkey) + return flow.asStateFlow() + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt deleted file mode 100644 index f6dc1d5..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt +++ /dev/null @@ -1,252 +0,0 @@ -package su.reya.coop.viewmodel.account - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.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 su.reya.coop.AppStorage -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.ExternalSignerProxy -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.repository.MediaRepository -import 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 AccountAuthDelegate( - private val nostr: Nostr, - private val storage: AppStorage, - private val mediaRepository: MediaRepository, - private val externalSignerHandler: ExternalSignerHandler? = null, - private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, - private val onError: (String) -> Unit, - private val onSignerReady: () -> Unit, - private val scope: CoroutineScope, -) { - 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 = _state.asStateFlow() - - fun init() { - checkNotificationBannerDismissedStatus() - login() - } - - 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 -> - onError("Login failed: ${e.message}") - _state.update { it.copy(signerRequired = true) } - } - } catch (e: Exception) { - onError("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) { - onError("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 = 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) { - onError("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) { - onError("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) { - onError("Identity creation failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt deleted file mode 100644 index 26f16c4..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt +++ /dev/null @@ -1,137 +0,0 @@ -package su.reya.coop.viewmodel.account - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Timestamp -import su.reya.coop.nostr.Nostr - -class AccountContactDelegate( - private val nostr: Nostr, - private val scope: CoroutineScope, - private val onError: (String) -> Unit, -) { - private val _contactList = MutableStateFlow>(emptySet()) - val contactList: StateFlow> = _contactList.asStateFlow() - - fun init() { - observeContactList() - } - - fun reset() { - _contactList.value = emptySet() - } - - private fun observeContactList() { - scope.launch { - nostr.waitUntilInitialized() - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - } - - private fun newContact(publicKey: PublicKey) { - if (publicKey in contactList.value) return - - scope.launch { - try { - val updated = contactList.value + publicKey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it + publicKey } - } catch (e: Exception) { - onError("Error: ${e.message}") - } - } - } - - fun addContact(address: String) { - scope.launch { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - onError("Invalid contact address: ${e.message}") - return@launch - } - - newContact(pubkey) - } - } - - 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) { - onError("Error: ${e.message}") - } - } - } - - fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.searchByAddress(query)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(null) - } - } - } - - fun searchByNostr(query: String, onResult: (List) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.searchByNostr(query)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(emptyList()) - } - } - } - - fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.verifyActivity(pubkey)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(null) - } - } - } - - fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.verifyContact(pubkey)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(false) - } - } - } - - fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.mutualContacts(pubkey)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(emptySet()) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt deleted file mode 100644 index 0929760..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt +++ /dev/null @@ -1,81 +0,0 @@ -package su.reya.coop.viewmodel.account - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -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.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.launch -import rust.nostr.sdk.PublicKey -import su.reya.coop.Profile -import su.reya.coop.nostr.Nostr -import su.reya.coop.repository.MediaRepository - -class AccountProfileDelegate( - private val nostr: Nostr, - private val mediaRepository: MediaRepository, - private val onError: (String) -> Unit, - private val scope: CoroutineScope, -) { - private val _isUpdatingProfile = MutableStateFlow(false) - val isUpdatingProfile: StateFlow = _isUpdatingProfile.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - val currentUserProfile: StateFlow = nostr.signer.publicKeyFlow - .flatMapLatest { pubkey -> - if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null) - } - .stateIn(scope, SharingStarted.WhileSubscribed(5000), null) - - @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) { - onError("Error: ${e.message}") - _isUpdatingProfile.value = false - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt deleted file mode 100644 index 99621aa..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt +++ /dev/null @@ -1,185 +0,0 @@ -package su.reya.coop.viewmodel.account - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl -import su.reya.coop.nostr.Nostr -import kotlin.time.Duration.Companion.seconds - -class AccountRelayDelegate( - private val nostr: Nostr, - private val onError: (String) -> Unit, - private val scope: CoroutineScope, -) { - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty = _isRelayListEmpty.asStateFlow() - - private val _currentUserRelayList = MutableStateFlow>(emptyMap()) - val currentUserRelayList = _currentUserRelayList.asStateFlow() - - private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) - val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow() - - fun reset() { - _isRelayListEmpty.value = false - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun checkRelayList() { - scope.launch { - val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() - println("user: ${currentUser.toBech32()}") - - // Small delay to ensure subscription is ready - delay(6.seconds) - - val relays = nostr.relays.getMsgRelays(currentUser) - 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) { - onError("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) { - onError("Error: ${e.message}") - } - } - } - - private suspend fun currentUserRelayListInternal(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - onError("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) { - onError("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) { - onError("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) { - onError("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) { - onError("Error: ${e.message}") - } - } - } - - private suspend fun currentUserMsgRelayListInternal(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - onError("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) { - onError("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) { - onError("Error: ${e.message}") - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt deleted file mode 100644 index fb5ed41..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt +++ /dev/null @@ -1,146 +0,0 @@ -package su.reya.coop.viewmodel.account - -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.StateFlow -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl -import rust.nostr.sdk.Timestamp -import su.reya.coop.AppStorage -import su.reya.coop.Profile -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.Nostr -import su.reya.coop.repository.MediaRepository -import su.reya.coop.viewmodel.BaseViewModel - -class AccountViewModel( - nostr: Nostr, - storage: AppStorage, - mediaRepository: MediaRepository, - externalSignerHandler: ExternalSignerHandler? = null, - defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, -) : BaseViewModel() { - private val relays = AccountRelayDelegate( - nostr = nostr, - onError = ::showError, - scope = viewModelScope, - ) - - private val auth = AccountAuthDelegate( - nostr = nostr, - storage = storage, - mediaRepository = mediaRepository, - externalSignerHandler = externalSignerHandler, - defaultDispatcher = defaultDispatcher, - onError = ::showError, - onSignerReady = { - profile.getUserMetadata() - relays.checkRelayList() - }, - scope = viewModelScope, - ) - - private val profile = AccountProfileDelegate( - nostr = nostr, - mediaRepository = mediaRepository, - onError = ::showError, - scope = viewModelScope, - ) - - private val contacts = AccountContactDelegate( - nostr = nostr, - onError = ::showError, - scope = viewModelScope, - ) - - - val state = auth.state - - val currentUserProfile: StateFlow = profile.currentUserProfile - - val isUpdatingProfile: StateFlow = profile.isUpdatingProfile - - val contactList: StateFlow> = contacts.contactList - - val isRelayListEmpty: StateFlow = relays.isRelayListEmpty - - val currentUserRelayList: StateFlow> = relays.currentUserRelayList - - val currentUserMsgRelayList: StateFlow> = relays.currentUserMsgRelayList - - init { - auth.init() - contacts.init() - } - - fun logout(onLogout: () -> Unit = {}) = auth.logout(onLogout) - - fun dismissNotificationBanner() = auth.dismissNotificationBanner() - - fun connectExternalSigner() = auth.connectExternalSigner() - - fun isExternalSignerAvailable(): Boolean = auth.isExternalSignerAvailable() - - fun importIdentity(secret: String, password: String? = null) = - auth.importIdentity(secret, password) - - fun createIdentity( - name: String, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null, - ) = auth.createIdentity(name, bio, picture, contentType) - - fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null, - ) { - profile.updateProfile(name, bio, picture, contentType) - } - - fun resetInternalState() { - contacts.reset() - relays.reset() - } - - fun addContact(address: String) = contacts.addContact(address) - - fun removeContact(publicKey: PublicKey) = contacts.removeContact(publicKey) - - fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) = - contacts.searchByAddress(query, onResult) - - fun searchByNostr(query: String, onResult: (List) -> Unit) = - contacts.searchByNostr(query, onResult) - - fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) = - contacts.verifyActivity(pubkey, onResult) - - fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) = - contacts.verifyContact(pubkey, onResult) - - fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) = - contacts.mutualContacts(pubkey, onResult) - - fun dismissRelayWarning() = relays.dismissRelayWarning() - - fun refetchMsgRelays() = relays.refetchMsgRelays() - - fun useDefaultMsgRelayList() = relays.useDefaultMsgRelayList() - - fun loadCurrentUserRelayList() = relays.loadCurrentUserRelayList() - - fun addInboxRelay(relay: String) = relays.addInboxRelay(relay) - - fun addOutboxRelay(relay: String) = relays.addOutboxRelay(relay) - - fun loadCurrentUserMsgRelayList() = relays.loadCurrentUserMsgRelayList() - - fun addMsgRelay(relay: String) = relays.addMsgRelay(relay) - - fun removeMsgRelay(relay: String) = relays.removeMsgRelay(relay) -}