From c3ef341f5daecd1166d0c9e63443c8632175f5de Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 30 Jun 2026 16:46:02 +0700 Subject: [PATCH] revert to single viewmodel --- .../androidMain/kotlin/su/reya/coop/App.kt | 42 +- .../kotlin/su/reya/coop/MainActivity.kt | 48 +- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 40 +- .../su/reya/coop/screens/ContactListScreen.kt | 16 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 59 +- .../su/reya/coop/screens/ImportScreen.kt | 16 +- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 4 +- .../su/reya/coop/screens/NewChatScreen.kt | 26 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 12 +- .../su/reya/coop/screens/OnboardingScreen.kt | 8 +- .../su/reya/coop/screens/ProfileScreen.kt | 18 +- .../su/reya/coop/screens/RelayScreen.kt | 18 +- .../su/reya/coop/screens/RequestListScreen.kt | 10 +- .../reya/coop/screens/UpdateProfileScreen.kt | 18 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 11 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 920 ++++++++++++++++++ .../reya/coop/viewmodels/AccountViewModel.kt | 210 ---- .../su/reya/coop/viewmodels/AppViewModel.kt | 92 -- .../su/reya/coop/viewmodels/ChatViewModel.kt | 221 ----- .../reya/coop/viewmodels/ProfileViewModel.kt | 216 ---- .../su/reya/coop/viewmodels/RelayViewModel.kt | 149 --- 21 files changed, 1048 insertions(+), 1106 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1f06149..869ed60 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -47,30 +47,9 @@ import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen -import su.reya.coop.viewmodels.AccountViewModel -import su.reya.coop.viewmodels.AppViewModel -import su.reya.coop.viewmodels.ChatViewModel -import su.reya.coop.viewmodels.ProfileViewModel -import su.reya.coop.viewmodels.RelayViewModel -val LocalAppViewModel = staticCompositionLocalOf { - error("No AppViewModel provided") -} - -val LocalAccountViewModel = staticCompositionLocalOf { - error("No AccountViewModel provided") -} - -val LocalChatViewModel = staticCompositionLocalOf { - error("No ChatViewModel provided") -} - -val LocalProfileViewModel = staticCompositionLocalOf { - error("No ProfileViewModel provided") -} - -val LocalRelayViewModel = staticCompositionLocalOf { - error("No RelayViewModel provided") +val LocalNostrViewModel = staticCompositionLocalOf { + error("No NostrViewModel provided") } val LocalSnackbarHostState = staticCompositionLocalOf { @@ -88,11 +67,7 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App( - appViewModel: AppViewModel, - accountViewModel: AccountViewModel, - chatViewModel: ChatViewModel, - profileViewModel: ProfileViewModel, - relayViewModel: RelayViewModel, + nostrViewModel: NostrViewModel, ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -101,7 +76,7 @@ fun App( val qrScanResult = remember { QrScanResult() } // Get the signer required state - val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle() + val signerRequired by nostrViewModel.signerRequired.collectAsStateWithLifecycle() // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -128,7 +103,7 @@ fun App( } LaunchedEffect(Unit) { - appViewModel.errorEvents.collect { message -> + nostrViewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -171,15 +146,12 @@ fun App( motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalAppViewModel provides appViewModel, - LocalAccountViewModel provides accountViewModel, - LocalChatViewModel provides chatViewModel, - LocalProfileViewModel provides profileViewModel, - LocalRelayViewModel provides relayViewModel, + LocalNostrViewModel provides nostrViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, ) { + NavDisplay( backStack = backStack, onBack = { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index fd4f8fa..ed2fcca 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -22,11 +22,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager -import su.reya.coop.viewmodels.AccountViewModel -import su.reya.coop.viewmodels.AppViewModel -import su.reya.coop.viewmodels.ChatViewModel -import su.reya.coop.viewmodels.ProfileViewModel -import su.reya.coop.viewmodels.RelayViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -39,31 +34,18 @@ class MainActivity : ComponentActivity() { private val secretStore = SecretStore(this@MainActivity) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val appVM = AppViewModel(NostrManager.instance, secretStore) - private val profileVM = ProfileViewModel(NostrManager.instance, appVM) - private val chatVM = ChatViewModel(NostrManager.instance, appVM) - private val accountVM = - AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner) - private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM) + private val nostrVM = NostrViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { - modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM - modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM - modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM - modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM - modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM + modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrVM else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } } } - private val appViewModel: AppViewModel by viewModels { factory } - private val accountViewModel: AccountViewModel by viewModels { factory } - private val chatViewModel: ChatViewModel by viewModels { factory } - private val profileViewModel: ProfileViewModel by viewModels { factory } - private val relayViewModel: RelayViewModel by viewModels { factory } + private val nostrViewModel: NostrViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -102,36 +84,20 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - accountViewModel.signerRequired.value == null + nostrViewModel.signerRequired.value == null } // Bind the lifecycle of the ViewModels - val lifecycle = ProcessLifecycleOwner.get().lifecycle - lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - appViewModel.viewModelScope.launch { - owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - coroutineScope { - launch { chatViewModel.bindObservers() } - launch { profileViewModel.bindObservers() } - } - } - } - } - }) - relayViewModel.observeSignerAndCheckRelays() + nostrViewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) setContent { App( - appViewModel = appViewModel, - accountViewModel = accountViewModel, - chatViewModel = chatViewModel, - profileViewModel = profileViewModel, - relayViewModel = relayViewModel, + nostrViewModel = nostrViewModel, ) } } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 6df1cae..0ba8360 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -77,10 +77,8 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.nostr.Room @@ -99,13 +97,12 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() // Get chat room by ID - val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -125,11 +122,11 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val displayName by remember(room) { - room?.nameFlow(profileViewModel) ?: flowOf("Loading...") + room?.nameFlow(nostrViewModel) ?: flowOf("Loading...") }.collectAsStateWithLifecycle("Loading...") val picture by remember(room) { - room?.pictureFlow(profileViewModel) ?: flowOf(null) + room?.pictureFlow(nostrViewModel) ?: flowOf(null) }.collectAsStateWithLifecycle(null) var text by remember { mutableStateOf("") } @@ -154,7 +151,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val type = context.contentResolver.getType(uri) // Send message - chatViewModel.sendFileMessage(id, file, type) + nostrViewModel.sendFileMessage(id, file, type) } catch (e: Exception) { snackbarHostState.showSnackbar("Error: ${e.message}") } @@ -167,7 +164,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LaunchedEffect(id) { // Get messages - val initialMessages = chatViewModel.getChatRoomMessages(id) + val initialMessages = nostrViewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) @@ -175,10 +172,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = false // Get msg relays for each member - chatViewModel.chatRoomConnect(id) + nostrViewModel.chatRoomConnect(id) // Handle new messages - chatViewModel.newEvents.collect { event -> + nostrViewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) @@ -357,7 +354,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { value = text, onValueChange = { text = it }, onSend = { - chatViewModel.sendMessage(id, text) + nostrViewModel.sendMessage(id, text) text = "" }, onUpload = { @@ -377,14 +374,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() @@ -394,11 +391,11 @@ fun ScreenerCard(room: Room) { LaunchedEffect(pubkey) { scope.launch { // Check contact - profileViewModel.verifyContact(pubkey).let { isContact = it } + nostrViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts - profileViewModel.mutualContacts(pubkey).let { mutualContacts = it } + nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity - profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it } + nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } } } @@ -516,9 +513,8 @@ fun DateSeparator(date: String) { fun ChatMessage( rumor: UnsignedEvent ) { - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val currentUser = accountViewModel.nostr.signer.currentUser + val nostrViewModel = LocalNostrViewModel.current + val currentUser = nostrViewModel.nostr.signer.currentUser val isMine = rumor.author() == currentUser val bubbleShape = if (isMine) { @@ -552,7 +548,7 @@ fun ChatMessage( onClick = { val id = rumor.id() if (id != null) { - val sent = chatViewModel.isMessageSent(id) + val sent = nostrViewModel.isMessageSent(id) println("Sent: $sent") } } 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 188e25d..9c8f607 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.PublicKey import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -66,9 +66,9 @@ import su.reya.coop.short fun ContactListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current - val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var openAddContactDialog by remember { mutableStateOf(false) } Scaffold( @@ -180,7 +180,7 @@ fun ContactListScreen() { @Composable fun AddContactDialog(onDismissRequest: () -> Unit) { val snackbarHostState = LocalSnackbarHostState.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } @@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { actions = { IconButton(onClick = { scope.launch { - val success = profileViewModel.addContact(contact) + val success = nostrViewModel.addContact(contact) if (success) onDismissRequest() } }) { @@ -283,8 +283,8 @@ fun ContactListItem( total: Int = 0, onClick: () -> Unit, ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } + val nostrViewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -293,7 +293,7 @@ fun ContactListItem( SegmentedListItem( onClick = onClick, - onLongClick = { profileViewModel.removeContact(pubkey) }, + onLongClick = { nostrViewModel.removeContact(pubkey) }, shapes = ListItemDefaults.segmentedShapes( index = index, count = total 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 02341e8..3431459 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -91,12 +91,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel -import su.reya.coop.LocalRelayViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -117,27 +113,22 @@ fun HomeScreen() { val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current - val relayViewModel = LocalRelayViewModel.current - + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = accountViewModel.nostr.signer.currentUser ?: return - val currentUserProfile = profileViewModel.getMetadata(currentUser) + val currentUser = nostrViewModel.nostr.signer.currentUser ?: return + val currentUserProfile = nostrViewModel.getMetadata(currentUser) val userProfile by currentUserProfile.collectAsStateWithLifecycle() - val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() - val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() - val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState() + val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() + val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() + val isBannerDismissed by nostrViewModel.isNotificationBannerDismissed.collectAsState() val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -165,7 +156,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - chatViewModel.getChatRooms() + nostrViewModel.getChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -173,7 +164,7 @@ fun HomeScreen() { runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -289,7 +280,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { appViewModel.dismissNotificationBanner() }, + onClick = { nostrViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -330,7 +321,7 @@ fun HomeScreen() { onRefresh = { scope.launch { isRefreshing = true - chatViewModel.refreshChatRooms() + nostrViewModel.refreshChatRooms() isRefreshing = false } }, @@ -400,7 +391,7 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = accountViewModel.nostr.signer.currentUser + val pubkey = nostrViewModel.nostr.signer.currentUser val shortPubkey = pubkey?.short() ?: "Not available" val userName = @@ -487,7 +478,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { relayViewModel.dismissRelayWarning() }, + onDismissRequest = { nostrViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -594,7 +585,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - relayViewModel.refetchMsgRelays(currentUser) + nostrViewModel.refetchMsgRelays(currentUser) } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -614,7 +605,7 @@ fun HomeScreen() { enabled = !isBusy, onClick = { scope.launch { - relayViewModel.useDefaultMsgRelayList() + nostrViewModel.useDefaultMsgRelayList() sheetState.hide() } }, @@ -637,18 +628,18 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) val firstName by remember(firstRoom) { - firstRoom?.nameFlow(profileViewModel) ?: flowOf("") + firstRoom?.nameFlow(nostrViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("Loading...") val secondName by remember(secondRoom) { - secondRoom?.nameFlow(profileViewModel) ?: flowOf("") + secondRoom?.nameFlow(nostrViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("") val supportingText = when { @@ -720,9 +711,9 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val profileViewModel = LocalProfileViewModel.current - val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null) + val nostrViewModel = LocalNostrViewModel.current + val displayName by remember(room) { room.nameFlow(nostrViewModel) }.collectAsStateWithLifecycle("Loading...") + val picture by remember(room) { room.pictureFlow(nostrViewModel) }.collectAsStateWithLifecycle(null) ListItem( modifier = Modifier.clickable(onClick = onClick), @@ -768,7 +759,7 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { val navigator = LocalNavigator.current - val accountViewModel = LocalAccountViewModel.current + val nostrViewModel = LocalNostrViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -798,7 +789,7 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { accountViewModel.logout() } }, + onClick = { onDismiss { nostrViewModel.logout() } }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError 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 7bfeaf5..b16cfb3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,10 +58,8 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -76,19 +74,17 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } // Get metadata when pubkey changes val metadata by remember(pubkey) { - pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null) + pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) val profile = metadata?.asRecord() @@ -250,10 +246,10 @@ fun ImportScreen() { onClick = { scope.launch { if (pubkey == null) { - accountViewModel.verifyIdentity(secret).let { pubkey = it } + nostrViewModel.verifyIdentity(secret).let { pubkey = it } } else { // Import the identity - accountViewModel.importIdentity(secret) + nostrViewModel.importIdentity(secret) // Navigate to the home screen navigator.navigate(Screen.Home) } 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 b62ed30..7b0db2a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -19,15 +19,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.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @Composable fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val accountViewModel = LocalAccountViewModel.current + val accountViewModel = LocalNostrViewModel.current val currentUser = accountViewModel.nostr.signer.currentUser ?: return Scaffold( 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 f2ded0c..3ee77d2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -55,9 +55,8 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -71,10 +70,9 @@ fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current - val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } val createGroup = remember { mutableStateOf(false) } @@ -96,12 +94,12 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = profileViewModel.searchByAddress(query) + val pubkey = nostrViewModel.searchByAddress(query) if (pubkey != null) { selectedReceivers.add(pubkey) } } else { - val results = profileViewModel.searchByNostr(query) + val results = nostrViewModel.searchByNostr(query) searchResults.clear() searchResults.addAll(results) } @@ -171,7 +169,7 @@ fun NewChatScreen() { ) { ExtendedFloatingActionButton( onClick = { - val roomId = chatViewModel.createChatRoom(selectedReceivers.toList()) + val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList()) navigator.navigate(Screen.Chat(roomId)) }, expanded = false, @@ -262,7 +260,7 @@ fun NewChatScreen() { items = searchResults, selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) }, ) @@ -273,7 +271,7 @@ fun NewChatScreen() { items = contactList.toList(), selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } ) @@ -290,8 +288,8 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } + val nostrViewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -376,8 +374,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } + val nostrViewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() 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 4b98253..51e6783 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -5,21 +5,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) ProfileEditor( title = "Create a new identity", @@ -28,7 +24,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - accountViewModel.createIdentity(name, bio, bytes, type) + nostrViewModel.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 90a34c3..6ec363d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -45,8 +45,8 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.getExpressiveFontFamily @@ -57,7 +57,7 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val accountViewModel = LocalAccountViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) @@ -158,9 +158,9 @@ fun OnboardingScreen() { FilledTonalButton( onClick = { scope.launch { - if (accountViewModel.isExternalSignerAvailable()) { + if (nostrViewModel.isExternalSignerAvailable()) { try { - accountViewModel.connectExternalSigner() + nostrViewModel.connectExternalSigner() navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } 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 edb46af..0caf75f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,9 +44,8 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -59,18 +58,17 @@ fun ProfileScreen(pubkey: String) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() - val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() val displayName = profile?.displayName ?: profile?.name ?: "No name" - val nip05 = profile?.nip05 ?: pubkey.short() + val nip05 = profile?.nip05 ?: pubkeyObj.short() val picture = profile?.picture val details = remember(profile) { @@ -161,7 +159,7 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -191,7 +189,7 @@ fun ProfileScreen(pubkey: String) { onClick = { val sendIntent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, pubkey.toBech32()) + putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32()) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) @@ -240,4 +238,4 @@ fun ProfileScreen(pubkey: String) { } } ) -} \ No newline at end of file +} 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 959f671..37515e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator -import su.reya.coop.LocalRelayViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState fun RelayScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val relayViewModel = LocalRelayViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() val msgRelayList = remember { mutableStateListOf() } @@ -96,8 +96,8 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayList.putAll(relayViewModel.currentUserRelayList()) - msgRelayList.addAll(relayViewModel.currentUserMsgRelayList()) + relayList.putAll(nostrViewModel.currentUserRelayList()) + msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) } Scaffold( @@ -321,7 +321,7 @@ fun RelayScreen() { return@launch } try { - relayViewModel.removeMsgRelay(relayToDelete!!) + nostrViewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } catch (e: Exception) { @@ -349,7 +349,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val relayViewModel = LocalRelayViewModel.current + val nostrViewModel = LocalNostrViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -401,17 +401,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - relayViewModel.addMsgRelay(relayAddress) + nostrViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - relayViewModel.addInboxRelay(relayAddress) + nostrViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - relayViewModel.addOutboxRelay(relayAddress) + nostrViewModel.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 53d6d73..5cb825a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -37,8 +37,8 @@ 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.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.nostr.RoomKind @@ -48,14 +48,14 @@ import su.reya.coop.nostr.RoomKind fun RequestListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val chatViewModel = LocalChatViewModel.current + val nostrViewModel = LocalNostrViewModel.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 nostrViewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -103,7 +103,7 @@ fun RequestListScreen() { onRefresh = { scope.launch { isRefreshing = true - chatViewModel.refreshChatRooms() + nostrViewModel.refreshChatRooms() isRefreshing = false } }, @@ -155,4 +155,4 @@ fun RequestListScreen() { } } } -} \ No newline at end of file +} 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 35d4a0b..ed8d900 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -5,23 +5,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.shared.ProfileEditor @Composable fun UpdateProfileScreen() { - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val currentUser = accountViewModel.nostr.signer.currentUser ?: return - val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) + val currentUser = nostrViewModel.nostr.signer.currentUser ?: return + val metadata by nostrViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() + val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) val profile = metadata?.asRecord() @@ -35,9 +31,9 @@ fun UpdateProfileScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - profileViewModel.updateProfile(name, bio, bytes, type) + nostrViewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } } ) -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt index 7184393..65fec77 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -7,10 +7,10 @@ import kotlinx.coroutines.flow.map import rust.nostr.sdk.PublicKey import su.reya.coop.nostr.Room import su.reya.coop.short -import su.reya.coop.viewmodels.ProfileViewModel +import su.reya.coop.NostrViewModel fun Room.nameFlow( - profileViewModel: ProfileViewModel, + nostrViewModel: NostrViewModel, currentUser: PublicKey? = null ): Flow { // Return early if there's a custom subject/room name @@ -19,7 +19,7 @@ fun Room.nameFlow( val displayMembers = if (isGroup()) members.take(2) else members.take(1) if (displayMembers.isEmpty()) return flowOf("Unknown") - return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray -> + return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { metadataArray -> val names = metadataArray.mapIndexed { i, metadata -> val profile = metadata?.asRecord() profile?.displayName?.takeIf { it.isNotBlank() } @@ -38,7 +38,8 @@ fun Room.nameFlow( } } -fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow { +fun Room.pictureFlow(nostrViewModel: NostrViewModel): Flow { val firstMember = members.firstOrNull() ?: return flowOf(null) - return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } + return nostrViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } } + diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt new file mode 100644 index 0000000..2740bda --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -0,0 +1,920 @@ +package su.reya.coop + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.Metadata +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.Tag +import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.blossom.BlossomClient +import su.reya.coop.nostr.ExternalSignerHandler +import su.reya.coop.nostr.ExternalSignerProxy +import su.reya.coop.nostr.Nostr +import su.reya.coop.nostr.Room +import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.nostr.roomId +import su.reya.coop.storage.SecretStorage +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class NostrViewModel( + val nostr: Nostr, + private val secretStore: SecretStorage, + private val externalSignerHandler: ExternalSignerHandler? = null, +) : ViewModel() { + private val _isNotificationBannerDismissed = MutableStateFlow(false) + val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() + + private val _signerRequired = MutableStateFlow(null) + val signerRequired = _signerRequired.asStateFlow() + + private val _isBusy = MutableStateFlow(false) + val isBusy = _isBusy.asStateFlow() + + private val _isPartialProcessedGiftWrap = MutableStateFlow(false) + val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() + + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty = _isRelayListEmpty.asStateFlow() + + private val _chatRooms = MutableStateFlow>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _sentReports = MutableSharedFlow>>() + val sentReport = _sentReports.asSharedFlow() + + private val _errorEvents = Channel(Channel.BUFFERED) + val errorEvents = _errorEvents.receiveAsFlow() + + private val _metadataStore = mutableMapOf>() + + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + + private val seenPublicKeys = mutableSetOf() + + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + // Skip the splash screen if a user is already logged in + if (nostr.signer.currentUser != null) { + _signerRequired.value = false + } + + // Check if the notification banner has been dismissed + checkNotificationBannerDismissedStatus() + + // Check local stored secret (secret key or bunker) + login() + + // Automatically reconnect bootstrap relays + reconnect() + + // Observe the signer state and verify the relay list + observeSignerAndCheckRelays() + + // Get all local stored metadata + getCacheMetadata() + } + + fun bindLifecycle(lifecycle: Lifecycle) { + viewModelScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + coroutineScope { + launch { runObserver() } + launch { runMetadataBatching() } + } + } + } + } + + override fun onCleared() { + super.onCleared() + + // Disconnect to all bootstrap relays + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } + + private fun showError(message: String) { + viewModelScope.launch { + _errorEvents.send(message) + } + } + + private fun checkNotificationBannerDismissedStatus() { + viewModelScope.launch { + _isNotificationBannerDismissed.value = + secretStore.get("notification_banner_dismissed") == "true" + } + } + + private fun reconnect() { + viewModelScope.launch { + nostr.waitUntilInitialized() + nostr.reconnect() + } + } + + private suspend fun runObserver() = coroutineScope { + // Observe message sync progress + launch { + nostr.messages.messageSyncState.collect { state -> + // When at least some messages are processed, allow UI to show the list + if (state.processedCount > 0) { + _isPartialProcessedGiftWrap.value = true + } + + // Refresh UI every 10 messages OR when sync is fully done + if (state.processedCount % 10 == 0 || !state.isSyncing) { + refreshChatRooms() + } + } + } + + // Observe new messages + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } + + if (existingRoom == null) { + val currentUser = nostr.signer.currentUser + if (currentUser != null) { + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + + // Observe contact list updates + launch { + nostr.profiles.contactListUpdates.collect { contacts -> + _contactList.value = contacts.toSet() + } + } + + // Observe metadata updates + launch { + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> + updateMetadata(pubkey, metadata) + } + } + } + + private suspend fun runMetadataBatching() = coroutineScope { + // Wait until the client is ready + nostr.waitUntilInitialized() + + val batch = mutableSetOf() + val timeout = 500L // 500ms timeout for batching + + while (true) { + // Get the first pubkey + val firstKey = metadataRequestChannel.receive() + batch.add(firstKey) + + // Get current time + val lastFlushTime = Clock.System.now().toEpochMilliseconds() + + while (batch.isNotEmpty()) { + // Get the next pubkey + val nextKey = withTimeoutOrNull(timeout.milliseconds) { + metadataRequestChannel.receive() + } + + // Only add the pubkey if it's not null + if (nextKey != null) batch.add(nextKey) + + // Get current time + val now = Clock.System.now().toEpochMilliseconds() + + // Check if the batch is full or timeout has passed + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { + val keysToRequest = batch.toList() + batch.clear() + + nostr.profiles.fetchMetadataBatch(keysToRequest) + } + } + } + } + + private fun getCacheMetadata() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + val results = nostr.profiles.getAllCacheMetadata() + results.forEach { (pubkey, metadata) -> + // Update the metadata state + updateMetadata(pubkey, metadata) + // Update seenPublicKeys to avoid duplicate requests + seenPublicKeys.add(pubkey) + } + } + } + + private fun login() { + viewModelScope.launch { + try { + val secret = withTimeoutOrNull(3.seconds) { + secretStore.get("user_signer") + } + + if (secret == null) { + _signerRequired.value = true + return@launch + } + + runCatching { + val signer = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _signerRequired.value = false + }.onFailure { e -> + showError("Login failed: ${e.message}") + _signerRequired.value = true + } + } catch (e: Exception) { + showError("Login failed: ${e.message}") + _signerRequired.value = true + } + } + } + + private fun observeSignerAndCheckRelays() { + viewModelScope.launch { + while (true) { + val pubkey = nostr.signer.currentUser + + if (pubkey != null) { + // Get chat rooms + val rooms = nostr.messages.getChatRooms() ?: emptySet() + if (rooms.isNotEmpty()) { + mergeChatRooms(rooms) + _isPartialProcessedGiftWrap.value = true + } + + // Get all metadata for the current user + nostr.profiles.getUserMetadata() + + // Small delay to ensure all relays are connected + delay(2.seconds) + + // Check if the relay list is empty + val relays = nostr.relays.getMsgRelays(pubkey) + if (relays.isEmpty()) _isRelayListEmpty.value = true + + break + } + + delay(500.milliseconds) + } + } + } + + private fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + viewModelScope.launch { + metadataRequestChannel.send(pubkey) + } + } + } + + private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { + _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + } + + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) { + requestMetadata(pubkey) + } + return flow.asStateFlow() + } + + fun currentUser(): PublicKey? { + return nostr.signer.currentUser + } + + fun logout() { + viewModelScope.launch { + try { + _isBusy.value = true + // Reset the nostr signer and prune the database + nostr.signer.switch(Keys.generate()) + nostr.prune() + } catch (e: Exception) { + showError("Logout encountered an error: ${e.message}") + } finally { + // Clear credentials from persistent storage + secretStore.clear("user_signer") + secretStore.clear("notification_banner_dismissed") + + // Reset all UI states + resetInternalState() + + _isBusy.value = false + _signerRequired.value = true + } + } + } + + private fun resetInternalState() { + _chatRooms.value = emptySet() + _contactList.value = emptySet() + _isPartialProcessedGiftWrap.value = false + _isRelayListEmpty.value = false + _isNotificationBannerDismissed.value = false + } + + fun dismissNotificationBanner() { + viewModelScope.launch { + secretStore.set("notification_banner_dismissed", "true") + _isNotificationBannerDismissed.value = true + } + } + + fun dismissRelayWarning() { + _isRelayListEmpty.value = false + } + + private suspend fun getOrInitAppKeys(): Keys { + val secret = secretStore.get("app_keys") + + // If app keys are already stored, use them + if (secret != null) { + return Keys.parse(secret) + } + + // Generate new app keys and save to the secret storage + val keys = Keys.generate() + secretStore.set("app_keys", keys.secretKey().toBech32()) + + return keys + } + + suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? { + try { + // Upload picture to Blossom + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + ) + + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = nostr.signer.get() + ) + + return descriptor?.url + } catch (e: Exception) { + showError("Error: ${e.message}") + return null + } + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + _isBusy.value = true + try { + val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + // Update the metadata state after successfully published + updateMetadata(currentUser, newMetadata) + // Update local state + _isBusy.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + _isBusy.value = true + + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + + try { + val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + // Create identity + nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + // Persist the secret in the secret storage + secretStore.set("user_signer", secret) + // Update local states + _isBusy.value = false + _signerRequired.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + private suspend fun createSigner(secret: String): AsyncNostrSigner { + return when { + secret.startsWith("nsec1") -> Keys.parse(secret) + + secret.startsWith("bunker://") -> { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = 50.seconds // or Duration.parse("50s") + NostrConnect(uri = bunker, appKeys, timeout, null) + } + + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler + ?: throw IllegalStateException("External signer not available on this platform") + + // Format: nip55://packageName/hexPubkey + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + + handler.setPackageName(packageName) + ExternalSignerProxy(handler, pubkey) + } + + else -> throw IllegalArgumentException("Invalid secret format") + } + } + + suspend fun verifyIdentity(secret: String): PublicKey? { + try { + val signer = createSigner(secret) + if (secret.startsWith("bunker://")) { + showError("Please approve the connection.") + } + return signer.getPublicKeyAsync() + } catch (e: Exception) { + showError("Error: ${e.message}") + return null + } + } + + suspend fun importIdentity(secret: String) { + _isBusy.value = true + try { + val signer = createSigner(secret) + // Update signer + nostr.setSigner(signer) + // Persist the secret in the secret storage + secretStore.set("user_signer", secret) + // Update local states + _signerRequired.value = false + _isBusy.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun connectExternalSigner() { + val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + _isBusy.value = true + try { + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), + ) + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + + // Update signer + nostr.setSigner(signer) + // Store the signer in the secret storage + secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") + // Update local states + _signerRequired.value = false + _isBusy.value = false + } catch (e: Exception) { + throw Exception("Notice: ${e.message}") + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + + suspend fun refetchMsgRelays(pubkey: PublicKey) { + val relays = nostr.relays.fetchMsgRelays(pubkey) + if (relays.isNotEmpty()) dismissRelayWarning() + } + + suspend fun useDefaultMsgRelayList() { + try { + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun currentUserRelayList(): 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() + } + } + + suspend fun addInboxRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays[relayUrl] = RelayMetadata.WRITE + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun addOutboxRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays[relayUrl] = RelayMetadata.READ + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun removeRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserRelayList().toMutableMap() + relays.remove(relayUrl) + + nostr.relays.setRelaylist(relays) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun currentUserMsgRelayList(): 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() + } + } + + suspend fun addMsgRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayList().toMutableSet() + relays.add(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun removeMsgRelay(relay: String) { + try { + val relayUrl = RelayUrl.parse(relay) + val relays = currentUserMsgRelayList().toMutableSet() + relays.remove(relayUrl) + + nostr.relays.setMsgRelays(relays.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + private suspend fun newContact(publicKey: PublicKey) { + if (publicKey in contactList.value) return + + try { + val updated = contactList.value + publicKey + // Publish new event + nostr.profiles.setContactList(updated.toList()) + // Optimistic local update + _contactList.update { it + publicKey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun addContact(address: String): Boolean { + val pubkey = try { + if (address.contains("@")) { + nostr.profiles.searchByAddress(address) + } else { + PublicKey.parse(address) + } + } catch (e: Exception) { + showError("Invalid contact address: ${e.message}") + return false + } + + return run { + newContact(pubkey) + true + } + } + + fun removeContact(publicKey: PublicKey) { + viewModelScope.launch { + if (publicKey !in contactList.value) return@launch + + try { + val updated = contactList.value - publicKey + // Publish new event + nostr.profiles.setContactList(updated.toList()) + // Optimistic local update + _contactList.update { it - publicKey } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun createChatRoom(to: List): Long { + try { + if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") + if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + + val currentUser = nostr.signer.currentUser ?: throw Exception("User not found") + + // 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 = _chatRooms.value.firstOrNull { it.id == 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 + _chatRooms.update { currentRooms -> + (currentRooms + room).sortedDescending().toSet() + } + + return room.id + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create room: ${e.message}") + } + } + + fun getChatRoom(id: Long): Room? { + return chatRooms.value.firstOrNull { it.id == id } + } + + private fun mergeChatRooms(rooms: Set) { + _chatRooms.update { currentRooms -> + val merged = currentRooms.associateBy { it.id }.toMutableMap() + // Add or update rooms from the database + rooms.forEach { room -> + merged[room.id] = room + } + // Return as a sorted set to maintain UI consistency + merged.values.sortedDescending().toSet() + } + } + + fun getChatRooms() { + viewModelScope.launch { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + mergeChatRooms(rooms) + } + } + + suspend fun refreshChatRooms() { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + mergeChatRooms(rooms) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + return nostr.messages.getChatRoomMessages(roomId) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + + return emptyList() + } + + 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") + } + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.sendMessage( + to = room.members, + content = message, + subject = room.subject, + replies = replies, + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + suspend fun sendFileMessage( + roomId: Long, + file: ByteArray?, + contentType: String? = "image/jpeg", + replies: List = emptyList() + ) { + if (file == null) return + + try { + val uri = blossomUpload(file, contentType) + ?: throw IllegalArgumentException("Failed to upload file") + + sendMessage(roomId, uri, replies) + } catch (e: Exception) { + throw IllegalArgumentException("Error: ${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) { + _chatRooms.update { currentRooms -> + currentRooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + } + } + + suspend fun searchByAddress(query: String): PublicKey? { + try { + return nostr.profiles.searchByAddress(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return null + } + + suspend fun searchByNostr(query: String): List { + try { + return nostr.profiles.searchByNostr(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return emptyList() + } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + return try { + nostr.profiles.verifyActivity(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + null + } + } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + return try { + nostr.profiles.verifyContact(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + false + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + return try { + nostr.profiles.mutualContacts(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + setOf() + } + } +} + diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt deleted file mode 100644 index 1fe5b7f..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt +++ /dev/null @@ -1,210 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.Keys -import rust.nostr.sdk.NostrConnect -import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.PublicKey -import su.reya.coop.nostr.ExternalSignerHandler -import su.reya.coop.nostr.ExternalSignerProxy -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.storage.SecretStorage -import kotlin.time.Duration.Companion.seconds - -class AccountViewModel( - val nostr: Nostr, - private val secretStore: SecretStorage, - private val appViewModel: AppViewModel, - private val externalSignerHandler: ExternalSignerHandler? = null, -) : ViewModel() { - private val _signerRequired = MutableStateFlow(null) - val signerRequired = _signerRequired.asStateFlow() - - init { - // Skip the splash screen if a user is already logged in - if (nostr.signer.currentUser != null) { - _signerRequired.value = false - } - // Check local stored secret - login() - } - - private fun login() { - viewModelScope.launch { - try { - val secret = withTimeoutOrNull(3.seconds) { - secretStore.get("user_signer") - } - - if (secret == null) { - _signerRequired.value = true - return@launch - } - - runCatching { - val signer = createSigner(secret) - nostr.setSigner(signer) - }.onSuccess { - _signerRequired.value = false - }.onFailure { e -> - appViewModel.showError("Login failed: ${e.message}") - _signerRequired.value = true - } - } catch (e: Exception) { - appViewModel.showError("Login failed: ${e.message}") - _signerRequired.value = true - } - } - } - - fun logout() { - viewModelScope.launch { - try { - appViewModel.setBusy(true) - // Reset the nostr signer and prune the database - nostr.signer.switch(Keys.generate()) - nostr.prune() - } catch (e: Exception) { - appViewModel.showError("Logout encountered an error: ${e.message}") - } finally { - // Clear credentials from persistent storage - secretStore.clear("user_signer") - secretStore.clear("notification_banner_dismissed") - // Reset local states - appViewModel.setBusy(false) - _signerRequired.value = true - } - } - } - - private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get("app_keys") - if (secret != null) return Keys.parse(secret) - - val keys = Keys.generate() - secretStore.set("app_keys", keys.secretKey().toBech32()) - - return keys - } - - private suspend fun createSigner(secret: String): AsyncNostrSigner { - return when { - secret.startsWith("nsec1") -> Keys.parse(secret) - secret.startsWith("bunker://") -> { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val timeout = 50.seconds - NostrConnect(uri = bunker, appKeys, timeout, null) - } - - secret.startsWith("nip55://") -> { - val handler = externalSignerHandler - ?: throw IllegalStateException("External signer not available on this platform") - val parts = secret.removePrefix("nip55://").split("/", limit = 2) - val packageName = parts[0] - val pubkey = PublicKey.parse(parts[1]) - handler.setPackageName(packageName) - ExternalSignerProxy(handler, pubkey) - } - - else -> throw IllegalArgumentException("Invalid secret format") - } - } - - suspend fun verifyIdentity(secret: String): PublicKey? { - try { - val signer = createSigner(secret) - if (secret.startsWith("bunker://")) { - appViewModel.showError("Please approve the connection.") - } - return signer.getPublicKeyAsync() - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return null - } - } - - suspend fun importIdentity(secret: String) { - appViewModel.setBusy(true) - - try { - val signer = createSigner(secret) - nostr.setSigner(signer) - secretStore.set("user_signer", secret) - _signerRequired.value = false - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - suspend fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = "image/jpeg", - ) { - appViewModel.setBusy(true) - - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - - try { - val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } - nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) - // Set credentials in persistent storage - secretStore.set("user_signer", secret) - // Update local state - _signerRequired.value = false - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - suspend fun connectExternalSigner() { - val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - appViewModel.setBusy(true) - try { - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - - val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") - val signer = ExternalSignerProxy(handler, result.pubkey) - nostr.setSigner(signer) - // Set credentials in persistent storage - secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") - // Update local state - _signerRequired.value = false - } catch (e: Exception) { - throw Exception("Notice: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt deleted file mode 100644 index e2eff28..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import su.reya.coop.blossom.BlossomClient -import su.reya.coop.nostr.Nostr -import su.reya.coop.storage.SecretStorage - -class AppViewModel( - private val nostr: Nostr, - private val secretStore: SecretStorage -) : ViewModel() { - private val _isBusy = MutableStateFlow(false) - val isBusy = _isBusy.asStateFlow() - - private val _isNotificationBannerDismissed = MutableStateFlow(false) - val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() - - private val _errorEvents = Channel(Channel.BUFFERED) - val errorEvents = _errorEvents.receiveAsFlow() - - init { - checkNotificationBannerDismissedStatus() - } - - fun setBusy(busy: Boolean) { - _isBusy.value = busy - } - - fun showError(message: String) { - viewModelScope.launch { - _errorEvents.send(message) - } - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - _isNotificationBannerDismissed.value = - secretStore.get("notification_banner_dismissed") == "true" - } - } - - fun dismissNotificationBanner() { - viewModelScope.launch { - secretStore.set("notification_banner_dismissed", "true") - _isNotificationBannerDismissed.value = true - } - } - - suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? { - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - ) - - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = nostr.signer.get() - ) - - return descriptor?.url - } - - override fun onCleared() { - super.onCleared() - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt deleted file mode 100644 index 897044d..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt +++ /dev/null @@ -1,221 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Kind -import rust.nostr.sdk.KindStandard -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Tag -import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.roomId - -class ChatViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel -) : ViewModel() { - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() - - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _isPartialProcessedGiftWrap = MutableStateFlow(false) - val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() - - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - - suspend fun bindObservers() = coroutineScope { - launch { - nostr.messages.messageSyncState.collect { state -> - if (state.processedCount > 0) { - _isPartialProcessedGiftWrap.value = true - } - if (state.processedCount % 10 == 0 || !state.isSyncing) { - refreshChatRooms() - } - } - } - - launch { - nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - - if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } - } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } - } - } - - fun createChatRoom(to: List): Long { - try { - val currentUser = - nostr.signer.currentUser ?: throw IllegalStateException("User not signed in") - if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") - - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") - .tags(to.map { Tag.publicKey(it) }) - .finalizeUnsigned(currentUser) - - val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } - - if (existingRoom != null) { - return existingRoom.id - } - - val room = Room.new(rumor, currentUser) - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } - - return room.id - } catch (e: Exception) { - throw IllegalArgumentException("Failed to create room: ${e.message}") - } - } - - fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } - } - - fun getChatRooms() { - viewModelScope.launch { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } - } - - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - fun mergeChatRooms(rooms: Set) { - _chatRooms.update { currentRooms -> - val merged = currentRooms.associateBy { it.id }.toMutableMap() - rooms.forEach { room -> - merged[room.id] = room - } - merged.values.sortedDescending().toSet() - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return emptyList() - } - - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.chatRoomConnect(room.members.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - fun sendMessage( - roomId: Long, - message: String, - replies: List = emptyList() - ) { - if (message.isEmpty()) { - appViewModel.showError("Message cannot be empty") - return - } - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.sendMessage( - to = room.members, - content = message, - subject = room.subject, - replies = replies, - onRumorCreated = { event -> - updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } - }, - ) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - suspend fun sendFileMessage( - roomId: Long, - file: ByteArray?, - contentType: String? = "image/jpeg", - replies: List = emptyList() - ) { - if (file == null) return - - try { - val uri = appViewModel.blossomUpload(file, contentType) - ?: throw IllegalArgumentException("Failed to upload file") - - sendMessage(roomId, uri, replies) - } catch (e: Exception) { - throw IllegalArgumentException("Error: ${e.message}") - } - } - - fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.messages.rumorMap[id] - return if (giftWrapId != null) { - nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false - } else { - false - } - } - - private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt deleted file mode 100644 index 3553c50..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt +++ /dev/null @@ -1,216 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.Metadata -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Timestamp -import su.reya.coop.nostr.Nostr -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds - -class ProfileViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel -) : ViewModel() { - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - - private val _metadataStore = mutableMapOf>() - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() - - init { - getCacheMetadata() - } - - suspend fun bindObservers() = coroutineScope { - launch { - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - - launch { - nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - } - } - - launch { runMetadataBatching() } - } - - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata - } - - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) { - requestMetadata(pubkey) - } - return flow.asStateFlow() - } - - private fun requestMetadata(pubkey: PublicKey) { - if (seenPublicKeys.add(pubkey)) { - viewModelScope.launch { - metadataRequestChannel.send(pubkey) - } - } - } - - private fun getCacheMetadata() { - viewModelScope.launch { - nostr.waitUntilInitialized() - val results = nostr.profiles.getAllCacheMetadata() - results.forEach { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - seenPublicKeys.add(pubkey) - } - } - } - - private suspend fun runMetadataBatching() = coroutineScope { - nostr.waitUntilInitialized() - val batch = mutableSetOf() - val timeout = 500L - - while (true) { - val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) - val lastFlushTime = Clock.System.now().toEpochMilliseconds() - - while (batch.isNotEmpty()) { - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - metadataRequestChannel.receive() - } - if (nextKey != null) batch.add(nextKey) - val now = Clock.System.now().toEpochMilliseconds() - - if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { - val keysToRequest = batch.toList() - batch.clear() - nostr.profiles.fetchMetadataBatch(keysToRequest) - } - } - } - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = "image/jpeg" - ) { - appViewModel.setBusy(true) - try { - val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } - val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - - updateMetadata(currentUser, newMetadata) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - suspend fun addContact(address: String): Boolean { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - appViewModel.showError("Invalid contact address: ${e.message}") - return false - } - - if (pubkey in contactList.value) return true - - return try { - val updated = contactList.value + pubkey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it + pubkey } - true - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - false - } - } - - fun removeContact(publicKey: PublicKey) { - viewModelScope.launch { - if (publicKey !in contactList.value) return@launch - try { - val updated = contactList.value - publicKey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it - publicKey } - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - suspend fun searchByAddress(query: String): PublicKey? { - try { - return nostr.profiles.searchByAddress(query) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return null - } - - suspend fun searchByNostr(query: String): List { - try { - return nostr.profiles.searchByNostr(query) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return emptyList() - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - return try { - nostr.profiles.verifyActivity(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - null - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - return try { - nostr.profiles.verifyContact(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - false - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - return try { - nostr.profiles.mutualContacts(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - setOf() - } - } - - fun getUserMetadata() { - viewModelScope.launch { - nostr.profiles.getUserMetadata() - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt deleted file mode 100644 index b779ef9..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt +++ /dev/null @@ -1,149 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl -import su.reya.coop.nostr.Nostr -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class RelayViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel, - private val chatViewModel: ChatViewModel, - private val profileViewModel: ProfileViewModel -) : ViewModel() { - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty = _isRelayListEmpty.asStateFlow() - - fun reconnect() { - viewModelScope.launch { - nostr.waitUntilInitialized() - nostr.reconnect() - } - } - - fun observeSignerAndCheckRelays() { - viewModelScope.launch { - while (true) { - val pubkey = nostr.signer.currentUser - if (pubkey != null) { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - if (rooms.isNotEmpty()) { - chatViewModel.mergeChatRooms(rooms) - // Note: isPartialProcessedGiftWrap is in ChatViewModel - // We might need to expose a way to set it or just let it be updated by observers - } - - profileViewModel.getUserMetadata() - - delay(2.seconds) - - val relays = nostr.relays.getMsgRelays(pubkey) - if (relays.isEmpty()) _isRelayListEmpty.value = true - break - } - delay(500.milliseconds) - } - } - } - - suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.relays.fetchMsgRelays(pubkey) - if (relays.isNotEmpty()) dismissRelayWarning() - } - - fun dismissRelayWarning() { - _isRelayListEmpty.value = false - } - - suspend fun useDefaultMsgRelayList() { - try { - val defaultRelays = nostr.relays.getDefaultMsgRelayList() - nostr.relays.setMsgRelays(defaultRelays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun currentUserRelayList(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return emptyMap() - } - } - - suspend fun addInboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.WRITE - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun addOutboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.READ - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun removeRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays.remove(relayUrl) - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun currentUserMsgRelayList(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return emptyList() - } - } - - suspend fun addMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.add(relayUrl) - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun removeMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.remove(relayUrl) - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } -}