From c3ef341f5daecd1166d0c9e63443c8632175f5de Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 30 Jun 2026 16:46:02 +0700 Subject: [PATCH 1/9] 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}") - } - } -} -- 2.49.1 From a6d40e44f26bdcc22f950e8e84fd5fa993ca1467 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 1 Jul 2026 13:40:21 +0700 Subject: [PATCH 2/9] . --- composeApp/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 6 +++--- shared/build.gradle.kts | 4 ++-- shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c822be5..1e7d78a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,8 +25,8 @@ kotlin { implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.androidx.core.splashscreen) implementation("su.reya:nostr-sdk-kmp:0.3.2") - implementation("io.coil-kt.coil3:coil-compose:3.4.0") - implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + implementation("io.coil-kt.coil3:coil-compose:3.5.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.alexzhirkevich:qrose:1.1.2") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56eb663..59fef3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,14 +10,14 @@ androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" androidx-splashscreen = "1.2.0" -composeMultiplatform = "1.11.0" +composeMultiplatform = "1.11.1" datastorePreferences = "1.2.1" junit = "4.13.2" -kotlin = "2.3.21" +kotlin = "2.4.0" kotlinx-serialization = "1.11.0" material3 = "1.11.0-alpha07" multiplatform-nav3-ui = "1.1.1" -ktor = "3.5.0" +ktor = "3.5.1" [libraries] androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b9d19bd..f311307 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -31,10 +31,10 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("su.reya:nostr-sdk-kmp:0.3.2") - implementation("com.squareup.okio:okio:3.16.2") + implementation("com.squareup.okio:okio:3.17.0") } androidMain.dependencies { implementation(libs.ktor.client.okhttp) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 2740bda..e77269a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -835,7 +835,7 @@ class NostrViewModel( replies: List = emptyList() ) { if (file == null) return - + try { val uri = blossomUpload(file, contentType) ?: throw IllegalArgumentException("Failed to upload file") -- 2.49.1 From 320a986212231d7a745f7de4792fd627ef15f621 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 10:41:10 +0700 Subject: [PATCH 3/9] refactor --- .../su/reya/coop/NostrForegroundService.kt | 1 - .../kotlin/su/reya/coop/screens/ChatScreen.kt | 67 ++++++---------- .../su/reya/coop/screens/ContactListScreen.kt | 14 ++-- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 68 ++++++---------- .../su/reya/coop/screens/ImportScreen.kt | 12 +-- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 8 +- .../su/reya/coop/screens/NewChatScreen.kt | 28 +++---- .../su/reya/coop/screens/ProfileScreen.kt | 22 +++--- .../su/reya/coop/screens/RequestListScreen.kt | 2 +- .../reya/coop/screens/UpdateProfileScreen.kt | 6 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 45 ----------- shared/build.gradle.kts | 2 + .../kotlin/su/reya/coop/NostrViewModel.kt | 78 ++++++++++--------- .../commonMain/kotlin/su/reya/coop/Profile.kt | 20 +++++ .../kotlin/su/reya/coop/{nostr => }/Room.kt | 64 ++++++++++++++- .../kotlin/su/reya/coop/nostr/Messaging.kt | 7 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 2 +- .../su/reya/coop/nostr/ProfileManager.kt | 6 +- .../kotlin/su/reya/coop/nostr/Signer.kt | 9 ++- 19 files changed, 223 insertions(+), 238 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Profile.kt rename shared/src/commonMain/kotlin/su/reya/coop/{nostr => }/Room.kt (74%) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index a604a0e..76ebe5b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import su.reya.coop.nostr.NostrManager -import su.reya.coop.nostr.roomId import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" 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 0ba8360..1a02a1c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -70,7 +70,6 @@ import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource @@ -80,15 +79,14 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.formatAsGroupHeader -import su.reya.coop.nostr.humanReadable -import su.reya.coop.nostr.roomId +import su.reya.coop.formatAsGroupHeader +import su.reya.coop.humanReadable +import su.reya.coop.rememberUiState +import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -101,6 +99,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val scope = rememberCoroutineScope() val listState = rememberLazyListState() + // Get current user + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + // Get chat room by ID val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } @@ -120,15 +121,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { return } - - val displayName by remember(room) { - room?.nameFlow(nostrViewModel) ?: flowOf("Loading...") - }.collectAsStateWithLifecycle("Loading...") - - val picture by remember(room) { - room?.pictureFlow(nostrViewModel) ?: flowOf(null) - }.collectAsStateWithLifecycle(null) - + val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } @@ -212,14 +205,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LoadingIndicator(modifier = Modifier.size(32.dp)) } else { Avatar( - picture = picture, - description = displayName, + picture = roomState.picture, + description = roomState.name, size = 32.dp, ) } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, ) } @@ -279,7 +272,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { items = messagesInGroup, key = { it.id()?.toBech32() ?: it.hashCode() } ) { - ChatMessage(it) + ChatMessage( + rumor = it, + isMine = currentUser?.publicKey == it.author() + ) } item { DateSeparator(dateHeader) @@ -381,12 +377,8 @@ fun ScreenerCard(room: Room) { var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsStateWithLifecycle() - - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { scope.launch { @@ -412,7 +404,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.size(120.dp), shape = MaterialShapes.Cookie12Sided.toShape(), @@ -422,7 +414,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -511,12 +503,9 @@ fun DateSeparator(date: String) { @Composable fun ChatMessage( - rumor: UnsignedEvent + rumor: UnsignedEvent, + isMine: Boolean = false, ) { - val nostrViewModel = LocalNostrViewModel.current - val currentUser = nostrViewModel.nostr.signer.currentUser - val isMine = rumor.author() == currentUser - val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) } else { @@ -542,17 +531,7 @@ fun ChatMessage( color = containerColor, contentColor = contentColor, shape = bubbleShape, - modifier = Modifier - .widthIn(max = 280.dp) - .clickable( - onClick = { - val id = rumor.id() - if (id != null) { - val sent = nostrViewModel.isMessageSent(id) - println("Sent: $sent") - } - } - ) + modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = rumor.content(), 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 9c8f607..e8c6cbf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -284,12 +284,8 @@ fun ContactListItem( onClick: () -> Unit, ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( onClick = onClick, @@ -300,15 +296,15 @@ fun ContactListItem( ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name, size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.titleMediumEmphasized, ) } 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 3431459..ab8e59f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -87,7 +87,6 @@ import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey @@ -95,15 +94,13 @@ import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.RoomKind -import su.reya.coop.nostr.ago +import su.reya.coop.ago +import su.reya.coop.rememberUiState import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -120,11 +117,9 @@ fun HomeScreen() { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = nostrViewModel.nostr.signer.currentUser ?: return - val currentUserProfile = nostrViewModel.getMetadata(currentUser) - - val userProfile by currentUserProfile.collectAsStateWithLifecycle() + val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() @@ -209,8 +204,8 @@ fun HomeScreen() { // User IconButton(onClick = { showBottomSheet = true }) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", size = 32.dp, ) } @@ -391,14 +386,6 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = nostrViewModel.nostr.signer.currentUser - val shortPubkey = pubkey?.short() ?: "Not available" - - val userName = - userProfile?.asRecord()?.displayName - ?: userProfile?.asRecord()?.name - ?: "No name" - val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> scope.launch { sheetState.hide() @@ -423,8 +410,8 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", shape = MaterialShapes.Cookie9Sided.toShape(), modifier = Modifier.fillMaxSize() ) @@ -434,7 +421,7 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Text( - text = userName, + text = userProfile?.name ?: "No name", style = MaterialTheme.typography.titleLargeEmphasized, ) } @@ -445,16 +432,15 @@ fun HomeScreen() { OutlinedButton( onClick = { scope.launch { - pubkey?.let { + userProfile?.publicKey?.let { val bech32 = it.toBech32() - val data = - ClipData.newPlainText(bech32, bech32) + val data = ClipData.newPlainText(bech32, bech32) clipboardManager.setClipEntry(ClipEntry(data)) } } }, ) { - Text(text = shortPubkey) + Text(text = userProfile?.shortPublicKey ?: "Unknown") } FilledIconButton( onClick = { @@ -585,7 +571,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - nostrViewModel.refetchMsgRelays(currentUser) + nostrViewModel.refetchMsgRelays() } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -634,28 +620,23 @@ fun NewRequests(requests: List) { val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstName by remember(firstRoom) { - firstRoom?.nameFlow(nostrViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("Loading...") - - val secondName by remember(secondRoom) { - secondRoom?.nameFlow(nostrViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("") + val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) + val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) val supportingText = when { - total == 1 && firstRoom != null -> { + total == 1 -> { val message = firstRoom.lastMessage ?: "" - "$firstName: $message" + "${firstRoomState.name}: $message" } total == 2 -> { - "$firstName and $secondName" + "${firstRoomState.name} and ${secondRoomState.name}" } total > 2 -> { val othersCount = total - 2 val othersText = if (othersCount == 1) "1 other" else "$othersCount others" - "$firstName, $secondName and $othersText" + "${firstRoomState.name}, ${secondRoomState.name} and $othersText" } else -> "" @@ -712,13 +693,12 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val nostrViewModel = LocalNostrViewModel.current - val displayName by remember(room) { room.nameFlow(nostrViewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(nostrViewModel) }.collectAsStateWithLifecycle(null) + val roomState by room.rememberUiState(nostrViewModel) ListItem( modifier = Modifier.clickable(onClick = onClick), leadingContent = { - Avatar(picture = picture, description = displayName) + Avatar(picture = roomState.picture, description = roomState.picture) }, headlineContent = { Row( @@ -726,7 +706,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f) ) 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 b16cfb3..707d705 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -65,7 +65,6 @@ import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -82,15 +81,10 @@ fun ImportScreen() { var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } - // Get metadata when pubkey changes - val metadata by remember(pubkey) { + val profile by remember(pubkey) { pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" - val picture = profile?.picture - LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { @@ -164,7 +158,7 @@ fun ImportScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.fillMaxSize(), shape = MaterialShapes.Cookie9Sided.toShape(), @@ -172,7 +166,7 @@ fun ImportScreen() { } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = profile?.name ?: "", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() 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 7b0db2a..505e806 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter @@ -27,8 +29,8 @@ import su.reya.coop.LocalSnackbarHostState fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val accountViewModel = LocalNostrViewModel.current - val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val nostrViewModel = LocalNostrViewModel.current + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -59,7 +61,7 @@ fun MyQrScreen() { horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = rememberQrCodePainter(currentUser.toBech32()), + painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""), contentDescription = "My QR" ) } 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 3ee77d2..095a876 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -289,12 +289,8 @@ fun ReceiverChip( onRemove: () -> Unit ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { InputChip( @@ -302,7 +298,7 @@ fun ReceiverChip( onClick = onRemove, label = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.SemiBold ) @@ -310,8 +306,8 @@ fun ReceiverChip( }, avatar = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "No name", size = 24.dp ) }, @@ -375,12 +371,8 @@ fun ContactListItem( onLongClick: () -> Unit ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( selected = isSelected, @@ -392,15 +384,15 @@ fun ContactListItem( ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "", size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "", style = MaterialTheme.typography.titleMediumEmphasized, ) } 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 0caf75f..3fefa14 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -63,19 +63,18 @@ fun ProfileScreen(pubkey: String) { val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } - val metadata by metadataFlow.collectAsStateWithLifecycle() + val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } + val profile by profileFlow.collectAsStateWithLifecycle() - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val nip05 = profile?.nip05 ?: pubkeyObj.short() - val picture = profile?.picture + val metadata = profile?.metadata?.asRecord() + val nip05 = metadata?.nip05 ?: pubkeyObj.short() + val picture = metadata?.picture val details = remember(profile) { listOf( - "Username:" to (profile?.name ?: "None"), - "Website:" to (profile?.website ?: "None"), - "â‚¿ Lightning Address:" to (profile?.lud16 ?: "None"), + "Username:" to (metadata?.name ?: "None"), + "Website:" to (metadata?.website ?: "None"), + "â‚¿ Lightning Address:" to (metadata?.lud16 ?: "None"), ) } @@ -133,7 +132,7 @@ fun ProfileScreen(pubkey: String) { verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -159,7 +158,8 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj)) + val roomId = + nostrViewModel.createChatRoom(listOf(pubkeyObj)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } 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 5cb825a..c686c90 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.RoomKind @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable 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 ed8d900..074a9b7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -15,11 +15,9 @@ fun UpdateProfileScreen() { val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - 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() + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val profile = currentUser?.metadata?.asRecord() ProfileEditor( title = "Update profile", diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt deleted file mode 100644 index 65fec77..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ /dev/null @@ -1,45 +0,0 @@ -package su.reya.coop.shared - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import rust.nostr.sdk.PublicKey -import su.reya.coop.nostr.Room -import su.reya.coop.short -import su.reya.coop.NostrViewModel - -fun Room.nameFlow( - nostrViewModel: NostrViewModel, - currentUser: PublicKey? = null -): Flow { - // Return early if there's a custom subject/room name - subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } - - val displayMembers = if (isGroup()) members.take(2) else members.take(1) - if (displayMembers.isEmpty()) return flowOf("Unknown") - - return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { metadataArray -> - val names = metadataArray.mapIndexed { i, metadata -> - val profile = metadata?.asRecord() - profile?.displayName?.takeIf { it.isNotBlank() } - ?: profile?.name?.takeIf { it.isNotBlank() } - ?: displayMembers[i].short() - } - - if (isGroup()) { - val combined = names.joinToString(", ") - val extraCount = members.size - names.size - if (extraCount > 0) "$combined, +$extraCount" else combined - } else { - val name = names.first() - if (displayMembers.first() == currentUser) "$name (you)" else name - } - } -} - -fun Room.pictureFlow(nostrViewModel: NostrViewModel): Flow { - val firstMember = members.firstOrNull() ?: return flowOf(null) - return nostrViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } -} - diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f311307..276ec31 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) kotlin("plugin.serialization") version libs.versions.kotlin.get() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index e77269a..b6e4d7e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -31,7 +34,6 @@ 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 @@ -44,16 +46,14 @@ 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 nostr: Nostr, private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : ViewModel() { @@ -87,21 +87,25 @@ class NostrViewModel( private val _errorEvents = Channel(Channel.BUFFERED) val errorEvents = _errorEvents.receiveAsFlow() - private val _metadataStore = mutableMapOf>() + private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + 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 - } + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) getMetadata(pubkey) else flowOf(null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + init { // Check if the notification banner has been dismissed checkNotificationBannerDismissedStatus() @@ -183,11 +187,9 @@ class NostrViewModel( 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() } - } + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } } else { updateRoomList(roomId, event) } @@ -206,7 +208,7 @@ class NostrViewModel( // Observe metadata updates launch { nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) + updateMetadata(pubkey, Profile(pubkey, metadata)) } } } @@ -257,7 +259,7 @@ class NostrViewModel( val results = nostr.profiles.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> // Update the metadata state - updateMetadata(pubkey, metadata) + updateMetadata(pubkey, Profile(pubkey, metadata)) // Update seenPublicKeys to avoid duplicate requests seenPublicKeys.add(pubkey) } @@ -295,9 +297,9 @@ class NostrViewModel( private fun observeSignerAndCheckRelays() { viewModelScope.launch { while (true) { - val pubkey = nostr.signer.currentUser + val currentUser = nostr.signer.getPublicKeyAsync() - if (pubkey != null) { + if (currentUser != null) { // Get chat rooms val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { @@ -312,7 +314,7 @@ class NostrViewModel( delay(2.seconds) // Check if the relay list is empty - val relays = nostr.relays.getMsgRelays(pubkey) + val relays = nostr.relays.getMsgRelays(currentUser) if (relays.isEmpty()) _isRelayListEmpty.value = true break @@ -331,22 +333,17 @@ class NostrViewModel( } } - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + private fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile } - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) { - requestMetadata(pubkey) - } + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = profiles.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 { @@ -445,7 +442,7 @@ class NostrViewModel( 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) + updateMetadata(currentUser, Profile(currentUser, newMetadata)) // Update local state _isBusy.value = false } catch (e: Exception) { @@ -573,8 +570,10 @@ class NostrViewModel( return externalSignerHandler?.isAvailable() == true } - suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.relays.fetchMsgRelays(pubkey) + suspend fun refetchMsgRelays() { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return + val relays = nostr.relays.fetchMsgRelays(currentUser) + if (relays.isNotEmpty()) dismissRelayWarning() } @@ -717,10 +716,13 @@ class NostrViewModel( 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") + if (to.isEmpty()) { + throw IllegalArgumentException("At least one recipient is required") + } - val currentUser = nostr.signer.currentUser ?: throw Exception("User not found") + // Get current user + val currentUser = nostr.signer.publicKeyFlow.value + ?: throw IllegalStateException("User not signed in") // Construct the rumor event val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt new file mode 100644 index 0000000..8b40cd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt @@ -0,0 +1,20 @@ +package su.reya.coop + +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.PublicKey + +data class Profile( + val publicKey: PublicKey, + val metadata: Metadata +) { + private val record by lazy { metadata.asRecord() } + + val name: String + get() = record.displayName ?: record.name ?: publicKey.short() + + val picture: String? + get() = record.picture + + val shortPublicKey: String + get() = publicKey.short() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt similarity index 74% rename from shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt rename to shared/src/commonMain/kotlin/su/reya/coop/Room.kt index eed2744..38d7014 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,5 +1,12 @@ -package su.reya.coop.nostr +package su.reya.coop +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus @@ -75,9 +82,60 @@ data class Room( return this.copy(lastMessage = message) } - fun isGroup(): Boolean { - return members.size > 1 + fun isGroup(): Boolean = members.size > 1 +} + +data class RoomUiState( + val name: String = "Loading...", + val picture: String? = null, + val isGroup: Boolean = false +) + +fun Room.uiStateFlow( + nostrViewModel: NostrViewModel, + currentUser: PublicKey? = null +): Flow { + val displayMembers = if (isGroup()) members.take(2) else members.take(1) + + if (!subject.isNullOrBlank()) { + return flowOf(RoomUiState(name = subject, isGroup = isGroup())) } + + return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> + val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() } + + val name = when { + isGroup() -> { + val combined = names.joinToString(", ") + val extra = members.size - names.size + if (extra > 0) "$combined, +$extra" else combined + } + + else -> { + val first = names.firstOrNull() ?: "Unknown" + if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first + } + } + + RoomUiState( + name = name, + picture = profiles.firstOrNull()?.picture, + isGroup = isGroup() + ) + } +} + +@Composable +fun Room.rememberUiState( + viewModel: NostrViewModel, + currentUser: PublicKey? = null +): State { + return remember(this, currentUser) { + uiStateFlow( + viewModel, + currentUser + ) + }.collectAsStateWithLifecycle(RoomUiState()) } fun UnsignedEvent.roomId(): Long { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 157f200..145b728 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync +import su.reya.coop.Room +import su.reya.coop.RoomKind +import su.reya.coop.roomId import kotlin.time.Duration data class MessageSyncState( @@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) { suspend fun getUserMessages(msgRelayList: Event) { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) // Ensure relay connections diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index b873b7a..913c8e0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -136,7 +136,7 @@ class Nostr { fun isSignedByUser(event: Event): Boolean { return try { - signer.currentUser == event.author() + signer.publicKeyFlow.value == event.author() } catch (e: Exception) { println("Failed to check if event is signed by user: ${e.message}") false diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index a326965..778a693 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) { suspend fun getUserMetadata() { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") // Get the latest metadata event val metadataFilter = @@ -146,7 +147,8 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") try { val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index bfeef8a..4872ac7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -1,5 +1,7 @@ package su.reya.coop.nostr +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull @@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { @Volatile private var signer: AsyncNostrSigner = initialSigner - @Volatile - var currentUser: PublicKey? = null - private set + private val _publicKeyFlow = MutableStateFlow(null) + val publicKeyFlow = _publicKeyFlow.asStateFlow() /** * Get the current signer. @@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { throw IllegalStateException("Failed to get public key from signer", e) } signer = newSigner - currentUser = pubkey + _publicKeyFlow.value = pubkey } override suspend fun getPublicKeyAsync(): PublicKey? { -- 2.49.1 From 666f5c1795a5b18dbc752294b05153a610231d34 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 12:13:05 +0700 Subject: [PATCH 4/9] . --- .../kotlin/su/reya/coop/MainActivity.kt | 18 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 169 +++++++++++------- 2 files changed, 105 insertions(+), 82 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index ed2fcca..fa3e047 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -10,16 +10,8 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.repeatOnLifecycle -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager import kotlin.system.exitProcess @@ -31,14 +23,15 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { - private val secretStore = SecretStore(this@MainActivity) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val nostrVM = NostrViewModel(NostrManager.instance, secretStore, androidSigner) + private val secretStore = SecretStore(this@MainActivity) + private val nostrViewModel = + NostrViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { - modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrVM + modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } @@ -87,9 +80,6 @@ class MainActivity : ComponentActivity() { nostrViewModel.signerRequired.value == null } - // Bind the lifecycle of the ViewModels - nostrViewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) - setContent { App( nostrViewModel = nostrViewModel, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b6e4d7e..5c11eb3 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -1,14 +1,13 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -18,13 +17,17 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf 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.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json @@ -52,25 +55,52 @@ import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +data class NostrAppState( + val isBusy: Boolean = false, + val isRelayListEmpty: Boolean = false, + val isSyncing: Boolean = false, + val isPartialProcessedGiftWrap: Boolean = false, + val isNotificationBannerDismissed: Boolean = false, + val signerRequired: Boolean? = null, +) + class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : ViewModel() { - private val _isNotificationBannerDismissed = MutableStateFlow(false) - val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } - private val _signerRequired = MutableStateFlow(null) - val signerRequired = _signerRequired.asStateFlow() + private val backgroundWorkTrigger = flow { + coroutineScope { + val observerJob = launch { runObserver() } + val batchingJob = launch { runMetadataBatching() } + try { + emit(Unit) + awaitCancellation() + } finally { + observerJob.cancel() + batchingJob.cancel() + } + } + } - 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 _appState = MutableStateFlow(NostrAppState()) + val appState: StateFlow = combine( + _appState, + nostr.messages.messageSyncState.map { it.isSyncing }, + backgroundWorkTrigger + ) { state, isSyncing, _ -> + state.copy(isSyncing = isSyncing) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NostrAppState() + ) private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() @@ -87,16 +117,23 @@ class NostrViewModel( private val _errorEvents = Channel(Channel.BUFFERED) val errorEvents = _errorEvents.receiveAsFlow() + private val profilesMutex = Mutex() private val profiles = mutableMapOf>() - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messages.messageSyncState - .map { it.isSyncing } + val isNotificationBannerDismissed = appState.map { it.isNotificationBannerDismissed } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val signerRequired = appState.map { it.signerRequired } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val isBusy = appState.map { it.isBusy } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isRelayListEmpty = appState.map { it.isRelayListEmpty } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isSyncing = appState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile = nostr.signer.publicKeyFlow @@ -122,17 +159,6 @@ class NostrViewModel( getCacheMetadata() } - fun bindLifecycle(lifecycle: Lifecycle) { - viewModelScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - coroutineScope { - launch { runObserver() } - launch { runMetadataBatching() } - } - } - } - } - override fun onCleared() { super.onCleared() @@ -152,8 +178,8 @@ class NostrViewModel( private fun checkNotificationBannerDismissedStatus() { viewModelScope.launch { - _isNotificationBannerDismissed.value = - secretStore.get("notification_banner_dismissed") == "true" + val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" + _appState.update { it.copy(isNotificationBannerDismissed = dismissed) } } } @@ -170,7 +196,7 @@ class NostrViewModel( 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 + _appState.update { it.copy(isPartialProcessedGiftWrap = true) } } // Refresh UI every 10 messages OR when sync is fully done @@ -270,11 +296,11 @@ class NostrViewModel( viewModelScope.launch { try { val secret = withTimeoutOrNull(3.seconds) { - secretStore.get("user_signer") + secretStore.get(KEY_USER_SIGNER) } if (secret == null) { - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } return@launch } @@ -282,14 +308,14 @@ class NostrViewModel( val signer = createSigner(secret) nostr.setSigner(signer) }.onSuccess { - _signerRequired.value = false + _appState.update { it.copy(signerRequired = false) } }.onFailure { e -> showError("Login failed: ${e.message}") - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } } } catch (e: Exception) { showError("Login failed: ${e.message}") - _signerRequired.value = true + _appState.update { it.copy(signerRequired = true) } } } } @@ -304,7 +330,7 @@ class NostrViewModel( val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { mergeChatRooms(rooms) - _isPartialProcessedGiftWrap.value = true + _appState.update { it.copy(isPartialProcessedGiftWrap = true) } } // Get all metadata for the current user @@ -315,7 +341,7 @@ class NostrViewModel( // Check if the relay list is empty val relays = nostr.relays.getMsgRelays(currentUser) - if (relays.isEmpty()) _isRelayListEmpty.value = true + if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } break } @@ -334,7 +360,11 @@ class NostrViewModel( } private fun updateMetadata(pubkey: PublicKey, profile: Profile) { - profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + viewModelScope.launch { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + } + } } fun getMetadata(pubkey: PublicKey): StateFlow { @@ -347,7 +377,7 @@ class NostrViewModel( fun logout() { viewModelScope.launch { try { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } // Reset the nostr signer and prune the database nostr.signer.switch(Keys.generate()) nostr.prune() @@ -355,14 +385,13 @@ class NostrViewModel( showError("Logout encountered an error: ${e.message}") } finally { // Clear credentials from persistent storage - secretStore.clear("user_signer") - secretStore.clear("notification_banner_dismissed") + secretStore.clear(KEY_USER_SIGNER) + secretStore.clear(KEY_BANNER_DISMISSED) // Reset all UI states resetInternalState() - _isBusy.value = false - _signerRequired.value = true + _appState.update { it.copy(isBusy = false, signerRequired = true) } } } } @@ -370,24 +399,28 @@ class NostrViewModel( private fun resetInternalState() { _chatRooms.value = emptySet() _contactList.value = emptySet() - _isPartialProcessedGiftWrap.value = false - _isRelayListEmpty.value = false - _isNotificationBannerDismissed.value = false + _appState.update { + it.copy( + isPartialProcessedGiftWrap = false, + isRelayListEmpty = false, + isNotificationBannerDismissed = false + ) + } } fun dismissNotificationBanner() { viewModelScope.launch { - secretStore.set("notification_banner_dismissed", "true") - _isNotificationBannerDismissed.value = true + secretStore.set(KEY_BANNER_DISMISSED, "true") + _appState.update { it.copy(isNotificationBannerDismissed = true) } } } fun dismissRelayWarning() { - _isRelayListEmpty.value = false + _appState.update { it.copy(isRelayListEmpty = false) } } private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get("app_keys") + val secret = secretStore.get(KEY_APP_KEYS) // If app keys are already stored, use them if (secret != null) { @@ -396,7 +429,7 @@ class NostrViewModel( // Generate new app keys and save to the secret storage val keys = Keys.generate() - secretStore.set("app_keys", keys.secretKey().toBech32()) + secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) return keys } @@ -436,7 +469,7 @@ class NostrViewModel( picture: ByteArray? = null, contentType: String? = null ) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) @@ -444,7 +477,7 @@ class NostrViewModel( // Update the metadata state after successfully published updateMetadata(currentUser, Profile(currentUser, newMetadata)) // Update local state - _isBusy.value = false + _appState.update { it.copy(isBusy = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -456,7 +489,7 @@ class NostrViewModel( picture: ByteArray?, contentType: String? = null ) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } val keys = Keys.generate() val secret = keys.secretKey().toBech32() @@ -466,10 +499,9 @@ class NostrViewModel( // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Persist the secret in the secret storage - secretStore.set("user_signer", secret) + secretStore.set(KEY_USER_SIGNER, secret) // Update local states - _isBusy.value = false - _signerRequired.value = false + _appState.update { it.copy(isBusy = false, signerRequired = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -517,16 +549,15 @@ class NostrViewModel( } suspend fun importIdentity(secret: String) { - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val signer = createSigner(secret) // Update signer nostr.setSigner(signer) // Persist the secret in the secret storage - secretStore.set("user_signer", secret) + secretStore.set(KEY_USER_SIGNER, secret) // Update local states - _signerRequired.value = false - _isBusy.value = false + _appState.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { showError("Error: ${e.message}") } @@ -534,7 +565,7 @@ class NostrViewModel( suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _isBusy.value = true + _appState.update { it.copy(isBusy = true) } try { val permissions = SignerPermissions.toJson( listOf( @@ -557,10 +588,12 @@ class NostrViewModel( // Update signer nostr.setSigner(signer) // Store the signer in the secret storage - secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") + secretStore.set( + KEY_USER_SIGNER, + "nip55://${result.packageName}/${result.pubkey.toHex()}" + ) // Update local states - _signerRequired.value = false - _isBusy.value = false + _appState.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { throw Exception("Notice: ${e.message}") } -- 2.49.1 From e5f3078c9cd840109c46e0619f35aade347c8532 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 13:36:12 +0700 Subject: [PATCH 5/9] unify error handler --- .../androidMain/kotlin/su/reya/coop/App.kt | 11 +- .../kotlin/su/reya/coop/MainActivity.kt | 9 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 13 +- .../su/reya/coop/screens/ImportScreen.kt | 9 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 8 +- .../su/reya/coop/screens/OnboardingScreen.kt | 6 +- .../kotlin/su/reya/coop/AuthViewModel.kt | 287 +++++++++++++++++ .../kotlin/su/reya/coop/BaseViewModel.kt | 46 +++ .../kotlin/su/reya/coop/ErrorManager.kt | 13 + .../kotlin/su/reya/coop/NostrViewModel.kt | 291 +----------------- 10 files changed, 396 insertions(+), 297 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 869ed60..ee7616c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalAuthViewModel = staticCompositionLocalOf { + error("No AuthViewModel provided") +} + val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } @@ -68,6 +72,7 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( nostrViewModel: NostrViewModel, + authViewModel: AuthViewModel, ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -76,7 +81,8 @@ fun App( val qrScanResult = remember { QrScanResult() } // Get the signer required state - val signerRequired by nostrViewModel.signerRequired.collectAsStateWithLifecycle() + val authState by authViewModel.state.collectAsStateWithLifecycle() + val signerRequired = authState.signerRequired // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -103,7 +109,7 @@ fun App( } LaunchedEffect(Unit) { - nostrViewModel.errorEvents.collect { message -> + ErrorManager.errors.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -147,6 +153,7 @@ fun App( ) { CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, + LocalAuthViewModel provides authViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index fa3e047..e816366 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -27,11 +27,14 @@ class MainActivity : ComponentActivity() { AndroidExternalSigner(this@MainActivity, externalSignerLauncher) private val secretStore = SecretStore(this@MainActivity) private val nostrViewModel = - NostrViewModel(NostrManager.instance, secretStore, androidSigner) + NostrViewModel(NostrManager.instance) + private val authViewModel = + AuthViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel + modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } @@ -39,6 +42,7 @@ class MainActivity : ComponentActivity() { } private val nostrViewModel: NostrViewModel by viewModels { factory } + private val authViewModel: AuthViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -77,12 +81,13 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - nostrViewModel.signerRequired.value == null + authViewModel.state.value.signerRequired == null } setContent { App( nostrViewModel = nostrViewModel, + authViewModel = authViewModel, ) } } 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 ab8e59f..498e766 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -90,6 +90,7 @@ import coop.composeapp.generated.resources.ic_scanner import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -111,6 +112,7 @@ fun HomeScreen() { val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) @@ -123,7 +125,9 @@ fun HomeScreen() { 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 authState by authViewModel.state.collectAsStateWithLifecycle() + val isBannerDismissed = authState.isNotificationBannerDismissed val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -275,7 +279,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { nostrViewModel.dismissNotificationBanner() }, + onClick = { authViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -615,6 +619,7 @@ fun HomeScreen() { fun NewRequests(requests: List) { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) @@ -693,6 +698,7 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val roomState by room.rememberUiState(nostrViewModel) ListItem( @@ -740,6 +746,7 @@ fun BottomMenuList( ) { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -769,7 +776,7 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { nostrViewModel.logout() } }, + onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } }, 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 707d705..aea7ee5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,6 +58,7 @@ 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.LocalAuthViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -74,10 +75,12 @@ fun ImportScreen() { val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() - val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) + val authState by authViewModel.state.collectAsStateWithLifecycle() + val isBusy = authState.isBusy var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } @@ -240,10 +243,10 @@ fun ImportScreen() { onClick = { scope.launch { if (pubkey == null) { - nostrViewModel.verifyIdentity(secret).let { pubkey = it } + authViewModel.verifyIdentity(secret).let { pubkey = it } } else { // Import the identity - nostrViewModel.importIdentity(secret) + authViewModel.importIdentity(secret) // Navigate to the home screen navigator.navigate(Screen.Home) } 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 51e6783..26e3837 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.Screen @@ -13,9 +14,12 @@ import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) + + val authState by authViewModel.state.collectAsStateWithLifecycle() + val isBusy = authState.isBusy ProfileEditor( title = "Create a new identity", @@ -24,7 +28,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - nostrViewModel.createIdentity(name, bio, bytes, type) + authViewModel.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 6ec363d..704a023 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -45,6 +45,7 @@ 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.LocalAuthViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -58,6 +59,7 @@ fun OnboardingScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) @@ -158,9 +160,9 @@ fun OnboardingScreen() { FilledTonalButton( onClick = { scope.launch { - if (nostrViewModel.isExternalSignerAvailable()) { + if (authViewModel.isExternalSignerAvailable()) { try { - nostrViewModel.connectExternalSigner() + authViewModel.connectExternalSigner() navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt new file mode 100644 index 0000000..7233789 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt @@ -0,0 +1,287 @@ +package su.reya.coop + +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import rust.nostr.sdk.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.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.SignerPermissions +import su.reya.coop.storage.SecretStorage +import kotlin.time.Duration.Companion.seconds + +data class AuthState( + val isBusy: Boolean = false, + val signerRequired: Boolean? = null, + val isNotificationBannerDismissed: Boolean = false, +) + +class AuthViewModel( + private val nostr: Nostr, + private val secretStore: SecretStorage, + private val externalSignerHandler: ExternalSignerHandler? = null, +) : BaseViewModel() { + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } + + private val _state = MutableStateFlow(AuthState()) + val state = _state.asStateFlow() + + init { + // Check if the notification banner has been dismissed + checkNotificationBannerDismissedStatus() + + // Check local stored secret (secret key or bunker) + login() + } + + private fun checkNotificationBannerDismissedStatus() { + viewModelScope.launch { + val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" + _state.update { it.copy(isNotificationBannerDismissed = dismissed) } + } + } + + private fun login() { + viewModelScope.launch { + try { + val secret = withTimeoutOrNull(3.seconds) { + secretStore.get(KEY_USER_SIGNER) + } + + if (secret == null) { + _state.update { it.copy(signerRequired = true) } + return@launch + } + + runCatching { + val signer = createSigner(secret) + nostr.setSigner(signer) + }.onSuccess { + _state.update { it.copy(signerRequired = false) } + }.onFailure { e -> + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } catch (e: Exception) { + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } + } + } + } + + fun logout(onLogout: () -> Unit = {}) { + viewModelScope.launch { + try { + _state.update { it.copy(isBusy = 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(KEY_USER_SIGNER) + secretStore.clear(KEY_BANNER_DISMISSED) + + // Call cleanup callback (e.g. to reset other ViewModels) + onLogout() + + _state.update { it.copy(isBusy = false, signerRequired = true) } + } + } + } + + fun dismissNotificationBanner() { + viewModelScope.launch { + secretStore.set(KEY_BANNER_DISMISSED, "true") + _state.update { it.copy(isNotificationBannerDismissed = true) } + } + } + + private suspend fun getOrInitAppKeys(): Keys { + val secret = secretStore.get(KEY_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(KEY_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") + + // 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) { + _state.update { it.copy(isBusy = true) } + try { + val signer = createSigner(secret) + // Update signer + nostr.setSigner(signer) + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) + // Update local states + _state.update { it.copy(signerRequired = false, isBusy = false) } + } catch (e: Exception) { + showError("Error: ${e.message}") + _state.update { it.copy(isBusy = false) } + } + } + + suspend fun connectExternalSigner() { + val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") + _state.update { it.copy(isBusy = 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( + KEY_USER_SIGNER, + "nip55://${result.packageName}/${result.pubkey.toHex()}" + ) + // Update local states + _state.update { it.copy(signerRequired = false, isBusy = false) } + } catch (e: Exception) { + _state.update { it.copy(isBusy = false) } + showError("Notice: ${e.message}") + } + } + + fun isExternalSignerAvailable(): Boolean { + return externalSignerHandler?.isAvailable() == true + } + + suspend fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + _state.update { it.copy(isBusy = 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 = bio, picture = avatarUrl) + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) + // Update local states + _state.update { it.copy(isBusy = false, signerRequired = false) } + } catch (e: Exception) { + showError("Error: ${e.message}") + _state.update { it.copy(isBusy = false) } + } + } + + private suspend fun blossomUpload( + file: ByteArray, + contentType: String? = "image/jpeg" + ): String? { + try { + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + ) + + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = nostr.signer.get() + ) + + return descriptor?.url + } catch (e: Exception) { + showError("Error: ${e.message}") + return null + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt new file mode 100644 index 0000000..c54c3af --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt @@ -0,0 +1,46 @@ +package su.reya.coop + +import androidx.lifecycle.ViewModel +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner +import su.reya.coop.blossom.BlossomClient + +abstract class BaseViewModel : ViewModel() { + protected val httpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + } + + protected fun showError(message: String) { + ErrorManager.showError(message) + } + + protected suspend fun blossomUpload( + signer: AsyncNostrSigner, + file: ByteArray, + contentType: String? = "image/jpeg" + ): String? { + return try { + val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = signer, + ) + descriptor?.url + } catch (e: Exception) { + showError("Upload failed: ${e.message}") + null + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt new file mode 100644 index 0000000..b4fc51e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt @@ -0,0 +1,13 @@ +package su.reya.coop + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object ErrorManager { + private val _errors = Channel(Channel.BUFFERED) + val errors = _errors.receiveAsFlow() + + fun showError(message: String) { + _errors.trySend(message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 5c11eb3..fffec80 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -1,10 +1,6 @@ package su.reya.coop -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.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation @@ -22,7 +18,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,27 +25,17 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock 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.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.SignerPermissions -import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -60,21 +45,9 @@ data class NostrAppState( val isRelayListEmpty: Boolean = false, val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, - val isNotificationBannerDismissed: Boolean = false, - val signerRequired: Boolean? = null, ) -class NostrViewModel( - private val nostr: Nostr, - private val secretStore: SecretStorage, - private val externalSignerHandler: ExternalSignerHandler? = null, -) : ViewModel() { - companion object { - private const val KEY_USER_SIGNER = "user_signer" - private const val KEY_APP_KEYS = "app_keys" - private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" - } - +class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { private val backgroundWorkTrigger = flow { coroutineScope { val observerJob = launch { runObserver() } @@ -114,18 +87,11 @@ class NostrViewModel( private val _sentReports = MutableSharedFlow>>() val sentReport = _sentReports.asSharedFlow() - private val _errorEvents = Channel(Channel.BUFFERED) - val errorEvents = _errorEvents.receiveAsFlow() - private val profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - val isNotificationBannerDismissed = appState.map { it.isNotificationBannerDismissed } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val signerRequired = appState.map { it.signerRequired } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) val isBusy = appState.map { it.isBusy } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap } @@ -143,12 +109,6 @@ class NostrViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) init { - // Check if the notification banner has been dismissed - checkNotificationBannerDismissedStatus() - - // Check local stored secret (secret key or bunker) - login() - // Automatically reconnect bootstrap relays reconnect() @@ -170,19 +130,6 @@ class NostrViewModel( } } - private fun showError(message: String) { - viewModelScope.launch { - _errorEvents.send(message) - } - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" - _appState.update { it.copy(isNotificationBannerDismissed = dismissed) } - } - } - private fun reconnect() { viewModelScope.launch { nostr.waitUntilInitialized() @@ -292,34 +239,6 @@ class NostrViewModel( } } - private fun login() { - viewModelScope.launch { - try { - val secret = withTimeoutOrNull(3.seconds) { - secretStore.get(KEY_USER_SIGNER) - } - - if (secret == null) { - _appState.update { it.copy(signerRequired = true) } - return@launch - } - - runCatching { - val signer = createSigner(secret) - nostr.setSigner(signer) - }.onSuccess { - _appState.update { it.copy(signerRequired = false) } - }.onFailure { e -> - showError("Login failed: ${e.message}") - _appState.update { it.copy(signerRequired = true) } - } - } catch (e: Exception) { - showError("Login failed: ${e.message}") - _appState.update { it.copy(signerRequired = true) } - } - } - } - private fun observeSignerAndCheckRelays() { viewModelScope.launch { while (true) { @@ -374,95 +293,21 @@ class NostrViewModel( return flow.asStateFlow() } - fun logout() { - viewModelScope.launch { - try { - _appState.update { it.copy(isBusy = 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(KEY_USER_SIGNER) - secretStore.clear(KEY_BANNER_DISMISSED) - - // Reset all UI states - resetInternalState() - - _appState.update { it.copy(isBusy = false, signerRequired = true) } - } - } - } - - private fun resetInternalState() { + fun resetInternalState() { _chatRooms.value = emptySet() _contactList.value = emptySet() _appState.update { it.copy( isPartialProcessedGiftWrap = false, isRelayListEmpty = false, - isNotificationBannerDismissed = false ) } } - fun dismissNotificationBanner() { - viewModelScope.launch { - secretStore.set(KEY_BANNER_DISMISSED, "true") - _appState.update { it.copy(isNotificationBannerDismissed = true) } - } - } - fun dismissRelayWarning() { _appState.update { it.copy(isRelayListEmpty = false) } } - private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get(KEY_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(KEY_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, @@ -471,11 +316,14 @@ class NostrViewModel( ) { _appState.update { it.copy(isBusy = true) } try { - val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + val avatarUrl = + picture?.let { blossomUpload(nostr.signer.get(), 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, Profile(currentUser, newMetadata)) + // Update local state _appState.update { it.copy(isBusy = false) } } catch (e: Exception) { @@ -483,126 +331,6 @@ class NostrViewModel( } } - suspend fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = null - ) { - _appState.update { it.copy(isBusy = 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(KEY_USER_SIGNER, secret) - // Update local states - _appState.update { it.copy(isBusy = false, signerRequired = 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) { - _appState.update { it.copy(isBusy = true) } - try { - val signer = createSigner(secret) - // Update signer - nostr.setSigner(signer) - // Persist the secret in the secret storage - secretStore.set(KEY_USER_SIGNER, secret) - // Update local states - _appState.update { it.copy(signerRequired = false, isBusy = false) } - } catch (e: Exception) { - showError("Error: ${e.message}") - } - } - - suspend fun connectExternalSigner() { - val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _appState.update { it.copy(isBusy = 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( - KEY_USER_SIGNER, - "nip55://${result.packageName}/${result.pubkey.toHex()}" - ) - // Update local states - _appState.update { it.copy(signerRequired = false, isBusy = false) } - } catch (e: Exception) { - throw Exception("Notice: ${e.message}") - } - } - - fun isExternalSignerAvailable(): Boolean { - return externalSignerHandler?.isAvailable() == true - } - suspend fun refetchMsgRelays() { val currentUser = nostr.signer.getPublicKeyAsync() ?: return val relays = nostr.relays.fetchMsgRelays(currentUser) @@ -872,10 +600,8 @@ class NostrViewModel( if (file == null) return try { - val uri = blossomUpload(file, contentType) - ?: throw IllegalArgumentException("Failed to upload file") - - sendMessage(roomId, uri, replies) + val uri = blossomUpload(nostr.signer.get(), file, contentType) + if (uri != null) sendMessage(roomId, uri, replies) } catch (e: Exception) { throw IllegalArgumentException("Error: ${e.message}") } @@ -952,4 +678,3 @@ class NostrViewModel( } } } - -- 2.49.1 From 830375f562de4862c1bfcaba5fa4d60ffb654a97 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 15:09:01 +0700 Subject: [PATCH 6/9] add chat viewmodel --- .../androidMain/kotlin/su/reya/coop/App.kt | 6 + .../kotlin/su/reya/coop/MainActivity.kt | 5 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 14 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 24 +- .../su/reya/coop/screens/NewChatScreen.kt | 8 +- .../su/reya/coop/screens/ProfileScreen.kt | 14 +- .../su/reya/coop/screens/RequestListScreen.kt | 6 +- .../kotlin/su/reya/coop/ChatViewModel.kt | 245 ++++++++++++++++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 242 +---------------- 9 files changed, 305 insertions(+), 259 deletions(-) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index ee7616c..067a5ba 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -52,6 +52,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalChatViewModel = staticCompositionLocalOf { + error("No ChatViewModel provided") +} + val LocalAuthViewModel = staticCompositionLocalOf { error("No AuthViewModel provided") } @@ -72,6 +76,7 @@ val LocalScanResult = staticCompositionLocalOf { @Composable fun App( nostrViewModel: NostrViewModel, + chatViewModel: ChatViewModel, authViewModel: AuthViewModel, ) { val context = LocalContext.current @@ -153,6 +158,7 @@ fun App( ) { CompositionLocalProvider( LocalNostrViewModel provides nostrViewModel, + LocalChatViewModel provides chatViewModel, LocalAuthViewModel provides authViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index e816366..0bcb529 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -28,12 +28,15 @@ class MainActivity : ComponentActivity() { private val secretStore = SecretStore(this@MainActivity) private val nostrViewModel = NostrViewModel(NostrManager.instance) + private val chatViewModel = + ChatViewModel(NostrManager.instance) private val authViewModel = AuthViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel + modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T @@ -42,6 +45,7 @@ class MainActivity : ComponentActivity() { } private val nostrViewModel: NostrViewModel by viewModels { factory } + private val chatViewModel: ChatViewModel by viewModels { factory } private val authViewModel: AuthViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { @@ -87,6 +91,7 @@ class MainActivity : ComponentActivity() { setContent { App( nostrViewModel = nostrViewModel, + chatViewModel = chatViewModel, authViewModel = authViewModel, ) } 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 1a02a1c..c53a642 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -76,6 +76,7 @@ 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.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -96,6 +97,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -103,7 +105,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() // Get chat room by ID - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -144,7 +146,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val type = context.contentResolver.getType(uri) // Send message - nostrViewModel.sendFileMessage(id, file, type) + chatViewModel.sendFileMessage(id, file, type) } catch (e: Exception) { snackbarHostState.showSnackbar("Error: ${e.message}") } @@ -157,7 +159,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LaunchedEffect(id) { // Get messages - val initialMessages = nostrViewModel.getChatRoomMessages(id) + val initialMessages = chatViewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) @@ -165,10 +167,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = false // Get msg relays for each member - nostrViewModel.chatRoomConnect(id) + chatViewModel.chatRoomConnect(id) // Handle new messages - nostrViewModel.newEvents.collect { event -> + chatViewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) @@ -350,7 +352,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { value = text, onValueChange = { text = it }, onSend = { - nostrViewModel.sendMessage(id, text) + chatViewModel.sendMessage(id, text) text = "" }, onUpload = { 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 498e766..b9d1ee2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -91,6 +91,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAuthViewModel +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -112,6 +113,7 @@ fun HomeScreen() { val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() @@ -120,11 +122,11 @@ fun HomeScreen() { val pullToRefreshState = rememberPullToRefreshState() val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() - val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() - val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() + val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() + val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val authState by authViewModel.state.collectAsStateWithLifecycle() val isBannerDismissed = authState.isNotificationBannerDismissed @@ -155,7 +157,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - nostrViewModel.getChatRooms() + chatViewModel.refreshChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -163,7 +165,7 @@ fun HomeScreen() { runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -320,7 +322,7 @@ fun HomeScreen() { onRefresh = { scope.launch { isRefreshing = true - nostrViewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, @@ -746,6 +748,7 @@ fun BottomMenuList( ) { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val authViewModel = LocalAuthViewModel.current val defaultMenuList = listOf( @@ -776,7 +779,14 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { authViewModel.logout(onLogout = nostrViewModel::resetInternalState) } }, + onClick = { + onDismiss { + authViewModel.logout(onLogout = { + nostrViewModel.resetInternalState() + chatViewModel.resetInternalState() + }) + } + }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError 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 095a876..2c2fb10 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -55,6 +55,7 @@ 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.LocalNostrViewModel import su.reya.coop.LocalScanResult @@ -71,6 +72,7 @@ fun NewChatScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } @@ -169,7 +171,7 @@ fun NewChatScreen() { ) { ExtendedFloatingActionButton( onClick = { - val roomId = nostrViewModel.createChatRoom(selectedReceivers.toList()) + val roomId = chatViewModel.createChatRoom(selectedReceivers.toList()) navigator.navigate(Screen.Chat(roomId)) }, expanded = false, @@ -260,7 +262,7 @@ fun NewChatScreen() { items = searchResults, selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) }, ) @@ -271,7 +273,7 @@ fun NewChatScreen() { items = contactList.toList(), selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = nostrViewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } ) 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 3fefa14..35cd311 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @@ -55,19 +56,20 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileScreen(pubkey: String) { + val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() - val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - - val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profile by profileFlow.collectAsStateWithLifecycle() val metadata = profile?.metadata?.asRecord() - val nip05 = metadata?.nip05 ?: pubkeyObj.short() + val nip05 = metadata?.nip05 ?: pubkey.short() val picture = metadata?.picture val details = remember(profile) { @@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) { scope.launch { try { val roomId = - nostrViewModel.createChatRoom(listOf(pubkeyObj)) + chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -189,7 +191,7 @@ fun ProfileScreen(pubkey: String) { onClick = { val sendIntent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, pubkeyObj.toBech32()) + putExtra(Intent.EXTRA_TEXT, pubkey.toBech32()) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) 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 c686c90..64985f1 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -37,6 +37,7 @@ 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 @@ -49,13 +50,14 @@ fun RequestListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() var isRefreshing by remember { mutableStateOf(false) } - val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -103,7 +105,7 @@ fun RequestListScreen() { onRefresh = { scope.launch { isRefreshing = true - nostrViewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt new file mode 100644 index 0000000..ebbc671 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt @@ -0,0 +1,245 @@ +package su.reya.coop + +import androidx.lifecycle.viewModelScope +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.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.RelayUrl +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.nostr.Nostr + +data class ChatState( + val isSyncing: Boolean = false, + val isPartialProcessedGiftWrap: Boolean = false, +) + +class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { + private val _state = MutableStateFlow(ChatState()) + val state = _state.asStateFlow() + + private val _chatRooms = MutableStateFlow>(emptySet()) + val chatRooms = _chatRooms.asStateFlow() + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _sentReports = MutableSharedFlow>>() + val sentReport = _sentReports.asSharedFlow() + + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + viewModelScope.launch { + nostr.waitUntilInitialized() + + // Observe message sync progress + launch { + nostr.messages.messageSyncState.collect { state -> + // When at least some messages are processed, allow UI to show the list + if (state.processedCount > 0) { + _state.update { it.copy(isPartialProcessedGiftWrap = 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.getPublicKeyAsync() ?: return@collect + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + + // Initial load of rooms + refreshChatRooms() + } + } + + fun createChatRoom(to: List): Long { + try { + if (to.isEmpty()) { + throw IllegalArgumentException("At least one recipient is required") + } + + // Get current user + val currentUser = nostr.signer.publicKeyFlow.value + ?: throw IllegalStateException("User not signed in") + + // Construct the rumor event + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") + .tags(to.map { Tag.publicKey(it) }) + .finalizeUnsigned(currentUser) + + // Check if the room already exists + val id = rumor.roomId() + val existingRoom = _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 } + } + + suspend fun refreshChatRooms() { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + _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() + } + } 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(nostr.signer.get(), file, contentType) + if (uri != null) 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() + } + } + + fun resetInternalState() { + _chatRooms.value = emptySet() + _state.update { + it.copy( + isPartialProcessedGiftWrap = false, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index fffec80..1d9f6df 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,11 +7,9 @@ import kotlinx.coroutines.awaitCancellation 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.combine import kotlinx.coroutines.flow.flatMapLatest @@ -25,16 +23,10 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -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.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.nostr.Nostr import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -43,12 +35,10 @@ import kotlin.time.Duration.Companion.seconds data class NostrAppState( val isBusy: Boolean = false, val isRelayListEmpty: Boolean = false, - val isSyncing: Boolean = false, - val isPartialProcessedGiftWrap: Boolean = false, ) class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { - private val backgroundWorkTrigger = flow { + private val alwaysRunTasks = flow { coroutineScope { val observerJob = launch { runObserver() } val batchingJob = launch { runMetadataBatching() } @@ -63,30 +53,16 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } private val _appState = MutableStateFlow(NostrAppState()) - val appState: StateFlow = combine( - _appState, - nostr.messages.messageSyncState.map { it.isSyncing }, - backgroundWorkTrigger - ) { state, isSyncing, _ -> - state.copy(isSyncing = isSyncing) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = NostrAppState() - ) - - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() + val appState: StateFlow = + combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NostrAppState() + ) 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 profilesMutex = Mutex() private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) @@ -94,12 +70,8 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { val isBusy = appState.map { it.isBusy } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isPartialProcessedGiftWrap = appState.map { it.isPartialProcessedGiftWrap } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val isRelayListEmpty = appState.map { it.isRelayListEmpty } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isSyncing = appState.map { it.isSyncing } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) @OptIn(ExperimentalCoroutinesApi::class) val currentUserProfile = nostr.signer.publicKeyFlow @@ -138,39 +110,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } 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) { - _appState.update { it.copy(isPartialProcessedGiftWrap = 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.getPublicKeyAsync() ?: return@collect - 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 -> @@ -245,13 +184,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { val currentUser = nostr.signer.getPublicKeyAsync() if (currentUser != null) { - // Get chat rooms - val rooms = nostr.messages.getChatRooms() ?: emptySet() - if (rooms.isNotEmpty()) { - mergeChatRooms(rooms) - _appState.update { it.copy(isPartialProcessedGiftWrap = true) } - } - // Get all metadata for the current user nostr.profiles.getUserMetadata() @@ -294,11 +226,9 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } fun resetInternalState() { - _chatRooms.value = emptySet() _contactList.value = emptySet() _appState.update { it.copy( - isPartialProcessedGiftWrap = false, isRelayListEmpty = false, ) } @@ -475,164 +405,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } - fun createChatRoom(to: List): Long { - try { - if (to.isEmpty()) { - throw IllegalArgumentException("At least one recipient is required") - } - - // Get current user - val currentUser = nostr.signer.publicKeyFlow.value - ?: throw IllegalStateException("User not signed in") - - // Construct the rumor event - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") - .tags(to.map { Tag.publicKey(it) }) - .finalizeUnsigned(currentUser) - - // Check if the room already exists - val id = rumor.roomId() - val existingRoom = _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(nostr.signer.get(), file, contentType) - if (uri != null) 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) -- 2.49.1 From 771441e97e319e08d02d5d33b47a864c5baf06df Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 15:29:49 +0700 Subject: [PATCH 7/9] . --- .../androidMain/kotlin/su/reya/coop/App.kt | 3 ++ .../kotlin/su/reya/coop/MainActivity.kt | 3 ++ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 5 +-- .../su/reya/coop/screens/NewIdentityScreen.kt | 2 - .../su/reya/coop/screens/OnboardingScreen.kt | 4 +- .../su/reya/coop/screens/RequestListScreen.kt | 2 - .../commonMain/kotlin/su/reya/coop/Room.kt | 1 + .../coop/{ => viewmodel}/AuthViewModel.kt | 44 +++---------------- .../coop/{ => viewmodel}/BaseViewModel.kt | 3 +- .../coop/{ => viewmodel}/ChatViewModel.kt | 5 ++- .../coop/{ => viewmodel}/NostrViewModel.kt | 3 +- 11 files changed, 22 insertions(+), 53 deletions(-) rename shared/src/commonMain/kotlin/su/reya/coop/{ => viewmodel}/AuthViewModel.kt (87%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => viewmodel}/BaseViewModel.kt (95%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => viewmodel}/ChatViewModel.kt (99%) rename shared/src/commonMain/kotlin/su/reya/coop/{ => viewmodel}/NostrViewModel.kt (99%) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 067a5ba..fb56b4e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -47,6 +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.viewmodel.AuthViewModel +import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.NostrViewModel val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0bcb529..ec77b12 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,6 +14,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore import su.reya.coop.nostr.NostrManager +import su.reya.coop.viewmodel.AuthViewModel +import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.NostrViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { 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 b9d1ee2..d0283e3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -60,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -127,7 +126,7 @@ fun HomeScreen() { val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - + val authState by authViewModel.state.collectAsStateWithLifecycle() val isBannerDismissed = authState.isNotificationBannerDismissed @@ -621,7 +620,6 @@ fun HomeScreen() { fun NewRequests(requests: List) { val navigator = LocalNavigator.current val nostrViewModel = LocalNostrViewModel.current - val authViewModel = LocalAuthViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) @@ -700,7 +698,6 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val nostrViewModel = LocalNostrViewModel.current - val authViewModel = LocalAuthViewModel.current val roomState by room.rememberUiState(nostrViewModel) ListItem( 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 26e3837..758d75d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -7,13 +7,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val nostrViewModel = LocalNostrViewModel.current val authViewModel = LocalAuthViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() 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 704a023..bbdf7d6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -47,7 +47,6 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalAuthViewModel 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 @@ -58,13 +57,12 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val nostrViewModel = LocalNostrViewModel.current val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() - + val annotatedText = buildAnnotatedString { append("By using Coop, you agree to accept\nour ") // Push "Terms of Use" link 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 64985f1..4580f5f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -39,7 +39,6 @@ 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.RoomKind import su.reya.coop.Screen @@ -49,7 +48,6 @@ import su.reya.coop.Screen fun RequestListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 38d7014..b1c6c3e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -15,6 +15,7 @@ import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.viewmodel.NostrViewModel import kotlin.time.Clock import kotlin.time.Instant diff --git a/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt similarity index 87% rename from shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 7233789..56b924d 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -1,21 +1,16 @@ -package su.reya.coop +package su.reya.coop.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.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.Json import rust.nostr.sdk.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.blossom.BlossomClient import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr @@ -241,11 +236,15 @@ class AuthViewModel( val secret = keys.secretKey().toBech32() try { - val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + val avatarUrl = + picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } + // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) + // Persist the secret in the secret storage secretStore.set(KEY_USER_SIGNER, secret) + // Update local states _state.update { it.copy(isBusy = false, signerRequired = false) } } catch (e: Exception) { @@ -253,35 +252,4 @@ class AuthViewModel( _state.update { it.copy(isBusy = false) } } } - - private suspend fun blossomUpload( - file: ByteArray, - contentType: String? = "image/jpeg" - ): String? { - try { - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - ) - - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = nostr.signer.get() - ) - - return descriptor?.url - } catch (e: Exception) { - showError("Error: ${e.message}") - return null - } - } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt similarity index 95% rename from shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt index c54c3af..d09c30a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel import io.ktor.client.HttpClient @@ -6,6 +6,7 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import rust.nostr.sdk.AsyncNostrSigner +import su.reya.coop.ErrorManager import su.reya.coop.blossom.BlossomClient abstract class BaseViewModel : ViewModel() { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt similarity index 99% rename from shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index ebbc671..caf6e23 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,10 +1,9 @@ -package su.reya.coop +package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope 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 @@ -19,7 +18,9 @@ import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.Room import su.reya.coop.nostr.Nostr +import su.reya.coop.roomId data class ChatState( val isSyncing: Boolean = false, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt similarity index 99% rename from shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 1d9f6df..4ab20c4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -1,4 +1,4 @@ -package su.reya.coop +package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -27,6 +27,7 @@ import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Timestamp +import su.reya.coop.Profile import su.reya.coop.nostr.Nostr import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds -- 2.49.1 From 785afbca084b75c19e1c6d14bce13baa8405f1b5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 16:09:53 +0700 Subject: [PATCH 8/9] . --- .../androidMain/kotlin/su/reya/coop/App.kt | 3 +- .../ErrorRepository.kt} | 4 +- .../reya/coop/repository/MediaRepository.kt | 39 ++++++++++++ .../su/reya/coop/viewmodel/AuthViewModel.kt | 8 ++- .../su/reya/coop/viewmodel/BaseViewModel.kt | 41 +----------- .../su/reya/coop/viewmodel/ChatViewModel.kt | 62 ++++++++++++------- .../su/reya/coop/viewmodel/NostrViewModel.kt | 24 +++---- 7 files changed, 99 insertions(+), 82 deletions(-) rename shared/src/commonMain/kotlin/su/reya/coop/{ErrorManager.kt => repository/ErrorRepository.kt} (82%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index fb56b4e..8d242ce 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import su.reya.coop.repository.ErrorRepository import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen @@ -117,7 +118,7 @@ fun App( } LaunchedEffect(Unit) { - ErrorManager.errors.collect { message -> + ErrorRepository.errors.collect { message -> snackbarHostState.showSnackbar(message) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt similarity index 82% rename from shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt rename to shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt index b4fc51e..8116902 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/ErrorManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt @@ -1,9 +1,9 @@ -package su.reya.coop +package su.reya.coop.repository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow -object ErrorManager { +object ErrorRepository { private val _errors = Channel(Channel.BUFFERED) val errors = _errors.receiveAsFlow() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt new file mode 100644 index 0000000..05fe570 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt @@ -0,0 +1,39 @@ +package su.reya.coop.repository + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner +import su.reya.coop.blossom.BlossomClient + +class MediaRepository { + private val httpClient = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + + suspend fun blossomUpload( + signer: AsyncNostrSigner, + file: ByteArray, + contentType: String? = "image/jpeg" + ): String? { + return try { + val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = signer, + ) + descriptor?.url + } catch (e: Exception) { + println("Upload failed: ${e.message}") + null + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 56b924d..5d8b226 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -15,6 +15,7 @@ import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.repository.MediaRepository import su.reya.coop.storage.SecretStorage import kotlin.time.Duration.Companion.seconds @@ -29,6 +30,8 @@ class AuthViewModel( private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : BaseViewModel() { + private val mediaRepository = MediaRepository() + companion object { private const val KEY_USER_SIGNER = "user_signer" private const val KEY_APP_KEYS = "app_keys" @@ -236,8 +239,9 @@ class AuthViewModel( val secret = keys.secretKey().toBech32() try { - val avatarUrl = - picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") + } // Create identity nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt index d09c30a..e1b0c13 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -1,47 +1,10 @@ package su.reya.coop.viewmodel import androidx.lifecycle.ViewModel -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import rust.nostr.sdk.AsyncNostrSigner -import su.reya.coop.ErrorManager -import su.reya.coop.blossom.BlossomClient +import su.reya.coop.repository.ErrorRepository abstract class BaseViewModel : ViewModel() { - protected val httpClient by lazy { - HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - } - protected fun showError(message: String) { - ErrorManager.showError(message) - } - - protected suspend fun blossomUpload( - signer: AsyncNostrSigner, - file: ByteArray, - contentType: String? = "image/jpeg" - ): String? { - return try { - val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = signer, - ) - descriptor?.url - } catch (e: Exception) { - showError("Upload failed: ${e.message}") - null - } + ErrorRepository.showError(message) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index caf6e23..e848f5f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -5,7 +5,7 @@ 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.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -20,19 +20,27 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.Room import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository import su.reya.coop.roomId data class ChatState( + val rooms: Set = emptySet(), val isSyncing: Boolean = false, val isPartialProcessedGiftWrap: Boolean = false, ) class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { - private val _state = MutableStateFlow(ChatState()) - val state = _state.asStateFlow() + private val mediaRepository = MediaRepository() - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() + private val _state = MutableStateFlow(ChatState()) + val state = combine( + _state, + nostr.messages.messageSyncState + ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ChatState() + ) private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -40,10 +48,13 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { private val _sentReports = MutableSharedFlow>>() val sentReport = _sentReports.asSharedFlow() - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + val chatRooms = state.map { it.rooms } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val isSyncing = state.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val isPartialProcessedGiftWrap = _state.map { it.isPartialProcessedGiftWrap } + val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { @@ -52,14 +63,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Observe message sync progress launch { - nostr.messages.messageSyncState.collect { state -> + nostr.messages.messageSyncState.collect { syncState -> // When at least some messages are processed, allow UI to show the list - if (state.processedCount > 0) { + if (syncState.processedCount > 0) { _state.update { it.copy(isPartialProcessedGiftWrap = true) } } // Refresh UI every 10 messages OR when sync is fully done - if (state.processedCount % 10 == 0 || !state.isSyncing) { + if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { refreshChatRooms() } } @@ -69,12 +80,16 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { launch { nostr.newEvents.collect { event -> val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } + val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } if (existingRoom == null) { val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } + _state.update { + it.copy( + rooms = (it.rooms + newRoom).sortedDescending().toSet() + ) + } } else { updateRoomList(roomId, event) } @@ -105,7 +120,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { // Check if the room already exists val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } + val existingRoom = _state.value.rooms.firstOrNull { it.id == id } // If the room already exists, return its ID if (existingRoom != null) { @@ -116,9 +131,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { val room = Room.new(rumor, currentUser) // Update the chat rooms state - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } + _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } return room.id } catch (e: Exception) { @@ -127,20 +140,20 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } + return _state.value.rooms.firstOrNull { it.id == id } } suspend fun refreshChatRooms() { try { val rooms = nostr.messages.getChatRooms() ?: emptySet() - _chatRooms.update { currentRooms -> - val merged = currentRooms.associateBy { it.id }.toMutableMap() + _state.update { currentState -> + val merged = currentState.rooms.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() + currentState.copy(rooms = merged.values.sortedDescending().toSet()) } } catch (e: Exception) { showError("Error: ${e.message}") @@ -202,7 +215,7 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { if (file == null) return try { - val uri = blossomUpload(nostr.signer.get(), file, contentType) + val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) if (uri != null) sendMessage(roomId, uri, replies) } catch (e: Exception) { throw IllegalArgumentException("Error: ${e.message}") @@ -221,8 +234,8 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { } private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> + _state.update { currentState -> + val updatedRooms = currentState.rooms.map { room -> if (room.id == roomId) { room.copy( lastMessage = newMessage.content(), @@ -232,13 +245,14 @@ class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { room } }.sortedDescending().toSet() + currentState.copy(rooms = updatedRooms) } } fun resetInternalState() { - _chatRooms.value = emptySet() _state.update { it.copy( + rooms = emptySet(), isPartialProcessedGiftWrap = false, ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 4ab20c4..5d46875 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -2,7 +2,6 @@ package su.reya.coop.viewmodel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey import rust.nostr.sdk.RelayMetadata @@ -29,6 +27,7 @@ import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.Timestamp import su.reya.coop.Profile import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -39,6 +38,8 @@ data class NostrAppState( ) class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { + private val mediaRepository = MediaRepository() + private val alwaysRunTasks = flow { coroutineScope { val observerJob = launch { runObserver() } @@ -92,17 +93,6 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { getCacheMetadata() } - override fun onCleared() { - super.onCleared() - - // Disconnect to all bootstrap relays - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } - private fun reconnect() { viewModelScope.launch { nostr.waitUntilInitialized() @@ -248,7 +238,13 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { _appState.update { it.copy(isBusy = true) } try { val avatarUrl = - picture?.let { blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") } + picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") -- 2.49.1 From e631d7ae8d69b78768172004588082eb59015b85 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 20:00:55 +0700 Subject: [PATCH 9/9] . --- .../kotlin/su/reya/coop/nostr/Nostr.kt | 4 ++- .../su/reya/coop/nostr/ProfileManager.kt | 15 ++------- .../kotlin/su/reya/coop/nostr/RelayManager.kt | 3 +- .../su/reya/coop/viewmodel/NostrViewModel.kt | 31 +++++++++---------- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 913c8e0..4483ba0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -79,6 +79,8 @@ class Nostr { // Initialize configurations for nostr client val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() + + // Initialize the authenticator val authenticator = SignerAuthenticator(signer) val idleTimeout = Duration.parse("5m") @@ -208,7 +210,7 @@ class Nostr { if (isSignedByUser(event = event)) { val pubkeys = event.tags().publicKeys() // Get mutual contacts - profiles.getMutualContacts(pubkeys) + profiles.syncMutualContacts(pubkeys) // Emit contact list update onContactListUpdate(pubkeys) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index 778a693..889e79b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -74,22 +74,13 @@ class ProfileManager(private val nostr: Nostr) { } } - suspend fun getMutualContacts(pubkeys: List) { + suspend fun syncMutualContacts(pubkeys: List) { try { val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "mutual-contacts", - closeOn = opts, - ) + client?.sync(filter, relays) } catch (e: Exception) { throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index 7c5569d..d763018 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -134,8 +134,9 @@ class RelayManager(private val nostr: Nostr) { val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val filter = Filter().kind(kind).author(publicKey).limit(1u) val events = client?.database()?.query(filter) + val event = events?.first() ?: return emptyList() - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + return nip17ExtractRelayList(event) } catch (e: Exception) { throw IllegalStateException("Failed to get msg relays: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 5d46875..5fb8c89 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf @@ -159,8 +161,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { // Wait until the client is ready nostr.waitUntilInitialized() - val results = nostr.profiles.getAllCacheMetadata() - results.forEach { (pubkey, metadata) -> + nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> // Update the metadata state updateMetadata(pubkey, Profile(pubkey, metadata)) // Update seenPublicKeys to avoid duplicate requests @@ -171,25 +172,21 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { private fun observeSignerAndCheckRelays() { viewModelScope.launch { - while (true) { - val currentUser = nostr.signer.getPublicKeyAsync() + // Wait until the client is ready + nostr.waitUntilInitialized() - if (currentUser != null) { - // Get all metadata for the current user - nostr.profiles.getUserMetadata() + // Wait until a signer is explicitly set (which updates publicKeyFlow) + val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() - // Small delay to ensure all relays are connected - delay(2.seconds) + // Get all metadata for the current user + nostr.profiles.getUserMetadata() - // Check if the relay list is empty - val relays = nostr.relays.getMsgRelays(currentUser) - if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } + // Small delay to ensure all relays are connected + delay(2.seconds) - break - } - - delay(500.milliseconds) - } + // Check if the relay list is empty + val relays = nostr.relays.getMsgRelays(currentUser) + if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } } } -- 2.49.1