diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 53a76dd..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 @@ -49,6 +54,7 @@ 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.ProfileCache @@ -56,15 +62,6 @@ 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") } @@ -81,9 +78,32 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( profileCache: ProfileCache, - chatViewModel: ChatViewModel, - accountViewModel: AccountViewModel, + 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) @@ -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) } } @@ -175,8 +195,6 @@ fun App( ) { CompositionLocalProvider( LocalProfileCache provides profileCache, - LocalChatViewModel provides chatViewModel, - LocalAccountViewModel provides accountViewModel, 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 5a5e9b8..266d69e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -12,7 +12,10 @@ 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.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel @@ -25,28 +28,40 @@ class MainActivity : ComponentActivity() { } private val profileCache by lazy { ProfileCache(NostrManager.instance) } + private val scope = MainScope() + + private val accountRepository by lazy { + val storage = AppStore(this@MainActivity) + val mediaRepository = MediaRepository() + val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) + AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner) + } + + private val chatRepository by lazy { + val mediaRepository = MediaRepository() + ChatRepository(NostrManager.instance, mediaRepository, scope) + } private val factory by lazy { object : ViewModelProvider.Factory { - private val storage = AppStore(this@MainActivity) - private val mediaRepository = MediaRepository() - private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository) - private val androidSigner = - AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val accountViewModel = - AccountViewModel(NostrManager.instance, storage, mediaRepository, androidSigner) - override fun create(modelClass: Class): T { - return when { - modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel - modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountViewModel + val result = when { + modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel( + chatRepository + ) + + modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel( + accountRepository + ) + else -> throw IllegalArgumentException("Unknown ViewModel class") - } as T + } + @Suppress("UNCHECKED_CAST") + return result as T } } } - private val chatViewModel: ChatViewModel by viewModels { factory } private val accountViewModel: AccountViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { @@ -92,8 +107,8 @@ class MainActivity : ComponentActivity() { setContent { App( profileCache = profileCache, - chatViewModel = chatViewModel, - accountViewModel = accountViewModel, + 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 26a9a1a..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.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 profileCache = LocalProfileCache.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 profileCache = LocalProfileCache.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() } 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 b51adc3..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,8 +89,6 @@ 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.LocalProfileCache import su.reya.coop.LocalScanResult @@ -103,17 +101,20 @@ import su.reya.coop.ago import su.reya.coop.shared.Avatar import su.reya.coop.shared.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 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 + ) } } } @@ -734,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 profileCache = LocalProfileCache.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 92efb52..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,8 +55,6 @@ 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.LocalProfileCache import su.reya.coop.LocalScanResult @@ -64,18 +62,19 @@ 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 profileCache = LocalProfileCache.current - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } 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 6f669be..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,7 +44,6 @@ 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.LocalProfileCache import su.reya.coop.LocalSnackbarHostState @@ -52,17 +51,20 @@ import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.shared.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 profileCache = LocalProfileCache.current - val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) } 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 93b8f4d..4d9f247 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.LocalProfileCache -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 profileCache = LocalProfileCache.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.currentUserRelayList.collectAsStateWithLifecycle() + val loadedMsgRelayList by viewModel.currentUserMsgRelayList.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 970a8b1..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,21 +30,20 @@ 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.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 profileCache = LocalProfileCache.current - val accountViewModel = LocalAccountViewModel.current var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } @@ -56,11 +54,11 @@ fun ScreenerCard(room: Room) { 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 71e258e..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,9 +62,6 @@ 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.LocalProfileCache import su.reya.coop.LocalSnackbarHostState @@ -74,31 +69,28 @@ import su.reya.coop.Room import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.formatAsGroupHeader -import su.reya.coop.uiStateFlow -import su.reya.coop.roomId import su.reya.coop.shared.Avatar +import su.reya.coop.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 profileCache = LocalProfileCache.current - val chatViewModel = LocalChatViewModel.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 @@ -118,12 +110,13 @@ fun ChatScreen( val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) .collectAsStateWithLifecycle(RoomUiState()) - var text by remember { mutableStateOf("") } - var loading by remember { mutableStateOf(true) } - var newOtherMessages by remember { mutableIntStateOf(0) } - var requireScreening by remember { mutableStateOf(screening) } - val messages = remember { mutableStateListOf() } + 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() } } } @@ -138,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) } } @@ -157,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) { @@ -256,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 @@ -340,7 +310,7 @@ fun ChatScreen( ) } FilledTonalButton( - onClick = { requireScreening = false }, + onClick = { viewModel.requireScreening = false }, modifier = Modifier .weight(1f) .size(ButtonDefaults.MediumContainerHeight) @@ -358,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/repository/AccountRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt new file mode 100644 index 0000000..940fc06 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/AccountRepository.kt @@ -0,0 +1,605 @@ +package su.reya.coop.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EncryptedSecretKey +import rust.nostr.sdk.Keys +import rust.nostr.sdk.NostrConnect +import rust.nostr.sdk.NostrConnectUri +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Timestamp +import su.reya.coop.AppStorage +import su.reya.coop.Profile +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.viewmodel.ErrorHost +import su.reya.coop.viewmodel.createErrorHost +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +data class AccountState( + val signerRequired: Boolean? = null, + val isNotificationBannerDismissed: Boolean = false, + val isImporting: Boolean = false, + val importError: String? = null, +) + +class AccountRepository( + private val nostr: Nostr, + private val storage: AppStorage, + private val mediaRepository: MediaRepository, + private val scope: CoroutineScope, + private val externalSignerHandler: ExternalSignerHandler? = null, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : ErrorHost by createErrorHost() { + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } + + private val _state = MutableStateFlow(AccountState()) + val state: StateFlow = _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 _currentUserRelayList = MutableStateFlow>(emptyMap()) + val currentUserRelayList: StateFlow> = + _currentUserRelayList.asStateFlow() + + private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) + val currentUserMsgRelayList: StateFlow> = _currentUserMsgRelayList.asStateFlow() + + init { + checkNotificationBannerDismissedStatus() + login() + observeContactList() + } + + private fun checkNotificationBannerDismissedStatus() { + scope.launch { + val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" + _state.update { it.copy(isNotificationBannerDismissed = dismissed) } + } + } + + private fun login() { + scope.launch { + try { + val secret = withTimeoutOrNull(5.seconds) { + storage.getSecret(KEY_USER_SIGNER) + } + + if (secret == null) { + _state.update { it.copy(signerRequired = true) } + return@launch + } + + runCatching { + val (signer, _) = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _state.update { it.copy(signerRequired = false) } + onSignerReady() + }.onFailure { e -> + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } catch (e: Exception) { + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun logout(onLogout: () -> Unit = {}) { + scope.launch { + try { + nostr.signer.switch(Keys.generate()) + nostr.prune() + } catch (e: Exception) { + showError("Logout encountered an error: ${e.message}") + } finally { + storage.clear(KEY_USER_SIGNER) + storage.clear(KEY_BANNER_DISMISSED) + onLogout() + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun dismissNotificationBanner() { + scope.launch { + storage.set(KEY_BANNER_DISMISSED, "true") + _state.update { it.copy(isNotificationBannerDismissed = true) } + } + } + + private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { + val secret = storage.getSecret(KEY_APP_KEYS) + if (secret != null) return@withContext Keys.parse(secret) + val keys = Keys.generate() + storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) + keys + } + + private suspend fun createSigner( + secret: String, + password: String? = null + ): Pair = withContext(defaultDispatcher) { + when { + secret.startsWith("nsec1") -> Keys.parse(secret) to null + + secret.startsWith("ncryptsec1") -> { + if (password == null) throw IllegalArgumentException("Password is required") + val enc = EncryptedSecretKey.fromBech32(secret) + val decrypted = enc.decrypt(password) + val keys = Keys(decrypted) + keys to keys.secretKey().toBech32() + } + + secret.startsWith("bunker://") -> { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = 50.seconds + NostrConnect(uri = bunker, appKeys, timeout, null) to null + } + + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler ?: throw IllegalStateException("Not available") + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) to null + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + + fun importIdentity(secret: String, password: String? = null) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val (signer, decryptedSecret) = createSigner(secret, password) + + nostr.setSigner(signer) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("Import failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun connectExternalSigner() { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val handler = + externalSignerHandler ?: throw IllegalStateException("Signer not available") + + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}" + + nostr.setSigner(signer) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, uri) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("External signer connection failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + scope.launch { + _state.update { it.copy(isImporting = true, importError = null) } + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") + } + + nostr.profiles.createIdentity( + keys = keys, + name = name, + bio = bio, + picture = avatarUrl + ) + onSignerReady() + + storage.setSecret(KEY_USER_SIGNER, secret) + _state.update { it.copy(signerRequired = false, isImporting = false) } + } catch (e: Exception) { + showError("Identity creation failed: ${e.message}") + _state.update { it.copy(isImporting = false, importError = e.message) } + } + } + } + + private fun onSignerReady() { + getUserMetadata() + checkRelayList() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun currentUserProfileFlow(pubkey: PublicKey) = merge( + flow { + nostr.waitUntilInitialized() + val cached = nostr.profiles.getAllCacheMetadata()[pubkey] + if (cached != null) emit(Profile(pubkey, cached)) + nostr.profiles.fetchMetadataBatch(listOf(pubkey)) + }, + nostr.profiles.metadataUpdates + .filter { (p, _) -> p == pubkey } + .map { (p, m) -> Profile(p, m) } + ) + + fun getUserMetadata() { + scope.launch { + nostr.profiles.getUserMetadata() + } + } + + fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + scope.launch { + _isUpdatingProfile.value = true + try { + val avatarUrl = picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } + nostr.profiles.updateProfile(name, bio, avatarUrl) + _isUpdatingProfile.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + _isUpdatingProfile.value = false + } + } + } + + private fun observeContactList() { + scope.launch { + nostr.waitUntilInitialized() + nostr.profiles.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + } + + fun resetInternalState() { + _contactList.value = emptySet() + _isRelayListEmpty.value = false + } + + fun addContact(address: String) { + scope.launch { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + showError("Invalid contact address: ${e.message}") + return@launch + } + + if (pubkey in _contactList.value) return@launch + + try { + val updated = _contactList.value + pubkey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it + pubkey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeContact(publicKey: PublicKey) { + scope.launch { + if (publicKey !in _contactList.value) return@launch + + try { + val updated = _contactList.value - publicKey + nostr.profiles.setContactList(updated.toList()) + _contactList.update { it - publicKey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { + scope.launch { + try { + onResult(nostr.profiles.searchByAddress(query)) + } catch (e: Exception) { + showError("Error: ${e.message}") + onResult(null) + } + } + } + + fun searchByNostr(query: String, onResult: (List) -> 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()) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun checkRelayList() { + scope.launch { + val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() + + val relays = withTimeoutOrNull(10.seconds) { + var result = nostr.relays.getMsgRelays(currentUser) + while (result.isEmpty()) { + delay(500.milliseconds) + result = nostr.relays.getMsgRelays(currentUser) + } + result + } ?: emptyList() + + if (relays.isEmpty()) _isRelayListEmpty.value = true + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + fun refetchMsgRelays() { + scope.launch { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch + val relays = nostr.relays.fetchMsgRelays(currentUser) + + if (relays.isNotEmpty()) dismissRelayWarning() + } + } + + fun useDefaultMsgRelayList() { + scope.launch { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserRelayList() { + scope.launch { + try { + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserRelayList.value = nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun currentUserRelayListInternal(): Map { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getRelayList(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyMap() + } + } + + fun addInboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun addOutboxRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayListInternal().toMutableMap() + relays.remove(relayUrl) + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun loadCurrentUserMsgRelayList() { + scope.launch { + try { + val currentUser = + nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + private suspend fun currentUserMsgRelayListInternal(): List { + try { + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + return nostr.relays.getMsgRelays(currentUser) + } catch (e: Exception) { + showError("Error: ${e.message}") + return emptyList() + } + } + + fun addMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.add(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun removeMsgRelay(relay: String) { + scope.launch { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayListInternal().toMutableSet() + relays.remove(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } +} 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 index f703017..f901a8b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt @@ -1,604 +1,56 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.EncryptedSecretKey -import rust.nostr.sdk.Keys -import rust.nostr.sdk.NostrConnect -import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Timestamp -import su.reya.coop.AppStorage import su.reya.coop.Profile -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.ExternalSignerProxy -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.repository.MediaRepository -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -data class AccountState( - val signerRequired: Boolean? = null, - val isNotificationBannerDismissed: Boolean = false, - val isImporting: Boolean = false, - val importError: String? = null, -) +import su.reya.coop.repository.AccountRepository +import su.reya.coop.repository.AccountState class AccountViewModel( - private val nostr: Nostr, - private val storage: AppStorage, - private val mediaRepository: MediaRepository, - private val externalSignerHandler: ExternalSignerHandler? = null, - private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, -) : ViewModel(), 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(viewModelScope, 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 _currentUserRelayList = MutableStateFlow>(emptyMap()) - val currentUserRelayList: StateFlow> = - _currentUserRelayList.asStateFlow() - - private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) - val currentUserMsgRelayList: StateFlow> = _currentUserMsgRelayList.asStateFlow() - - init { - checkNotificationBannerDismissedStatus() - login() - observeContactList() - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true" - _state.update { it.copy(isNotificationBannerDismissed = dismissed) } - } - } - - private fun login() { - viewModelScope.launch { - try { - val secret = withTimeoutOrNull(5.seconds) { - storage.getSecret(KEY_USER_SIGNER) - } - - if (secret == null) { - _state.update { it.copy(signerRequired = true) } - return@launch - } - - runCatching { - val (signer, _) = createSigner(secret) - nostr.setSigner(signer) - }.onSuccess { - _state.update { it.copy(signerRequired = false) } - onSignerReady() - }.onFailure { e -> - showError("Login failed: ${e.message}") - _state.update { it.copy(signerRequired = true) } - } - } catch (e: Exception) { - showError("Login failed: ${e.message}") - _state.update { it.copy(signerRequired = true) } - } - } - } - - fun logout(onLogout: () -> Unit = {}) { - viewModelScope.launch { - try { - nostr.signer.switch(Keys.generate()) - nostr.prune() - } catch (e: Exception) { - showError("Logout encountered an error: ${e.message}") - } finally { - storage.clear(KEY_USER_SIGNER) - storage.clear(KEY_BANNER_DISMISSED) - onLogout() - _state.update { it.copy(signerRequired = true) } - } - } - } - - fun dismissNotificationBanner() { - viewModelScope.launch { - storage.set(KEY_BANNER_DISMISSED, "true") - _state.update { it.copy(isNotificationBannerDismissed = true) } - } - } - - private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) { - val secret = storage.getSecret(KEY_APP_KEYS) - if (secret != null) return@withContext Keys.parse(secret) - val keys = Keys.generate() - storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32()) - keys - } - - private suspend fun createSigner( - secret: String, - password: String? = null - ): Pair = withContext(defaultDispatcher) { - when { - secret.startsWith("nsec1") -> Keys.parse(secret) to null - - secret.startsWith("ncryptsec1") -> { - if (password == null) throw IllegalArgumentException("Password is required") - val enc = EncryptedSecretKey.fromBech32(secret) - val decrypted = enc.decrypt(password) - val keys = Keys(decrypted) - keys to keys.secretKey().toBech32() - } - - secret.startsWith("bunker://") -> { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = 50.seconds - NostrConnect(uri = bunker, appKeys, timeout, null) to null - } - - secret.startsWith("nip55://") -> { - val handler = externalSignerHandler ?: throw IllegalStateException("Not available") - val parts = secret.removePrefix("nip55://").split("/", limit = 2) - val packageName = parts[0] - val pubkey = PublicKey.parse(parts[1]) - - handler.setPackageName(packageName) - ExternalSignerProxy(handler, pubkey) to null - } - - else -> throw IllegalArgumentException("Invalid secret format") - } - } - - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } - - fun importIdentity(secret: String, password: String? = null) { - viewModelScope.launch { - _state.update { it.copy(isImporting = true, importError = null) } - try { - val (signer, decryptedSecret) = createSigner(secret, password) - - nostr.setSigner(signer) - onSignerReady() - - storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret) - _state.update { it.copy(signerRequired = false, isImporting = false) } - } catch (e: Exception) { - showError("Import failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } - } - } - } - - fun connectExternalSigner() { - viewModelScope.launch { - _state.update { it.copy(isImporting = true, importError = null) } - try { - val handler = - externalSignerHandler ?: throw IllegalStateException("Signer not available") - - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - - val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") - val signer = ExternalSignerProxy(handler, result.pubkey) - val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}" - - nostr.setSigner(signer) - onSignerReady() - - storage.setSecret(KEY_USER_SIGNER, uri) - _state.update { it.copy(signerRequired = false, isImporting = false) } - } catch (e: Exception) { - showError("External signer connection failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } - } - } - } - - fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = null - ) { - viewModelScope.launch { - _state.update { it.copy(isImporting = true, importError = null) } - try { - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - val avatarUrl = picture?.let { - mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") - } - - nostr.profiles.createIdentity( - keys = keys, - name = name, - bio = bio, - picture = avatarUrl - ) - onSignerReady() - - storage.setSecret(KEY_USER_SIGNER, secret) - _state.update { it.copy(signerRequired = false, isImporting = false) } - } catch (e: Exception) { - showError("Identity creation failed: ${e.message}") - _state.update { it.copy(isImporting = false, importError = e.message) } - } - } - } - - private fun onSignerReady() { - getUserMetadata() - checkRelayList() - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun currentUserProfileFlow(pubkey: PublicKey) = merge( - flow { - nostr.waitUntilInitialized() - val cached = nostr.profiles.getAllCacheMetadata()[pubkey] - if (cached != null) emit(Profile(pubkey, cached)) - nostr.profiles.fetchMetadataBatch(listOf(pubkey)) - }, - nostr.profiles.metadataUpdates - .filter { (p, _) -> p == pubkey } - .map { (p, m) -> Profile(p, m) } - ) - - fun getUserMetadata() { - viewModelScope.launch { - nostr.profiles.getUserMetadata() - } - } - - fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = null - ) { - viewModelScope.launch { - _isUpdatingProfile.value = true - try { - val avatarUrl = picture?.let { - mediaRepository.blossomUpload( - nostr.signer.get(), - it, - contentType ?: "image/jpeg" - ) - } - nostr.profiles.updateProfile(name, bio, avatarUrl) - _isUpdatingProfile.value = false - } catch (e: Exception) { - showError("Error: ${e.message}") - _isUpdatingProfile.value = false - } - } - } - - private fun observeContactList() { - viewModelScope.launch { - nostr.waitUntilInitialized() - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - } - - fun resetInternalState() { - _contactList.value = emptySet() - _isRelayListEmpty.value = false - } - - fun addContact(address: String) { - viewModelScope.launch { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - showError("Invalid contact address: ${e.message}") - return@launch - } - - if (pubkey in _contactList.value) return@launch - - try { - val updated = _contactList.value + pubkey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it + pubkey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun removeContact(publicKey: PublicKey) { - viewModelScope.launch { - if (publicKey !in _contactList.value) return@launch - - try { - val updated = _contactList.value - publicKey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it - publicKey } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.searchByAddress(query)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(null) - } - } - } - - fun searchByNostr(query: String, onResult: (List) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.searchByNostr(query)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(emptyList()) - } - } - } - - fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.verifyActivity(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(null) - } - } - } - - fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.verifyContact(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(false) - } - } - } - - fun mutualContacts(pubkey: PublicKey, onResult: (Set) -> Unit) { - viewModelScope.launch { - try { - onResult(nostr.profiles.mutualContacts(pubkey)) - } catch (e: Exception) { - showError("Error: ${e.message}") - onResult(emptySet()) - } - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - fun checkRelayList() { - viewModelScope.launch { - val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() - - val relays = withTimeoutOrNull(10.seconds) { - var result = nostr.relays.getMsgRelays(currentUser) - while (result.isEmpty()) { - delay(500.milliseconds) - result = nostr.relays.getMsgRelays(currentUser) - } - result - } ?: emptyList() - - if (relays.isEmpty()) _isRelayListEmpty.value = true - } - } - - fun dismissRelayWarning() { - _isRelayListEmpty.value = false - } - - fun refetchMsgRelays() { - viewModelScope.launch { - val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch - val relays = nostr.relays.fetchMsgRelays(currentUser) - - if (relays.isNotEmpty()) dismissRelayWarning() - } - } - - fun useDefaultMsgRelayList() { - viewModelScope.launch { - try { - val defaultRelays = nostr.relays.getDefaultMsgRelayList() - nostr.relays.setMsgRelays(defaultRelays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun loadCurrentUserRelayList() { - viewModelScope.launch { - try { - val currentUser = - nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - _currentUserRelayList.value = nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - private suspend fun currentUserRelayListInternal(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyMap() - } - } - - fun addInboxRelay(relay: String) { - viewModelScope.launch { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayListInternal().toMutableMap() - relays[relayUrl] = RelayMetadata.WRITE - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun addOutboxRelay(relay: String) { - viewModelScope.launch { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayListInternal().toMutableMap() - relays[relayUrl] = RelayMetadata.READ - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun removeRelay(relay: String) { - viewModelScope.launch { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayListInternal().toMutableMap() - relays.remove(relayUrl) - - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun loadCurrentUserMsgRelayList() { - viewModelScope.launch { - try { - val currentUser = - nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - _currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - private suspend fun currentUserMsgRelayListInternal(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - showError("Error: ${e.message}") - return emptyList() - } - } - - fun addMsgRelay(relay: String) { - viewModelScope.launch { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayListInternal().toMutableSet() - relays.add(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } - - fun removeMsgRelay(relay: String) { - viewModelScope.launch { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayListInternal().toMutableSet() - relays.remove(relayUrl) - - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - } + 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 currentUserRelayList: StateFlow> = repository.currentUserRelayList + val currentUserMsgRelayList: StateFlow> = repository.currentUserMsgRelayList + + 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 getUserMetadata() = repository.getUserMetadata() + 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 checkRelayList() = repository.checkRelayList() + 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 removeRelay(relay: String) = repository.removeRelay(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..7071a50 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -0,0 +1,78 @@ +package su.reya.coop.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import rust.nostr.sdk.EventId +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.Profile +import su.reya.coop.Room +import su.reya.coop.repository.AccountRepository +import su.reya.coop.repository.ChatRepository +import su.reya.coop.roomId + +class ChatScreenViewModel( + val id: Long, + val screening: Boolean, + private val accountRepository: AccountRepository, + private val chatRepository: ChatRepository, +) : ViewModel(), ErrorHost by chatRepository { + val currentUser: StateFlow = 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) + } + + fun isMessageSent(id: EventId): Boolean { + return chatRepository.isMessageSent(id) + } +} 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 a916a52..0ce0e83 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,254 +1,41 @@ package su.reya.coop.viewmodel import androidx.lifecycle.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 kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow 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.repository.MediaRepository -import su.reya.coop.roomId -import su.reya.coop.viewmodel.createErrorHost -import su.reya.coop.viewmodel.ErrorHost - -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, -) : ViewModel(), ErrorHost by createErrorHost() { - 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 newEvents: SharedFlow = repository.newEvents + 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() + fun createChatRoom(to: List): Long = repository.createChatRoom(to) + fun getChatRoom(id: Long): Room? = repository.getChatRoom(id) + fun refreshChatRooms() = repository.refreshChatRooms() + fun loadChatRoomMessages(roomId: Long, onResult: (List) -> Unit) = + repository.loadChatRoomMessages(roomId, onResult) - 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 chatRoomConnect(roomId: Long) = repository.chatRoomConnect(roomId) + fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) = + repository.sendMessage(roomId, message, replies) fun sendFileMessage( roomId: Long, file: ByteArray?, contentType: String? = "image/jpeg", replies: List = emptyList() - ) { - if (file == null) return + ) = repository.sendFileMessage(roomId, file, contentType, replies) - 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 isMessageSent(id: EventId): Boolean = repository.isMessageSent(id) + fun resetInternalState() = repository.resetInternalState() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt index f6a6324..dd51ea8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ProfileCache.kt @@ -96,10 +96,6 @@ class ProfileCache( } } - /** - * Returns a [StateFlow] for the profile of the given [pubkey]. - * Triggers a metadata fetch if the profile is not yet cached. - */ fun getMetadata(pubkey: PublicKey): StateFlow { val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } if (flow.value == null) requestMetadata(pubkey)