diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 47f75ba..1f06149 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -47,9 +47,30 @@ 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 LocalNostrViewModel = staticCompositionLocalOf { - error("No NostrViewModel provided") +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 LocalSnackbarHostState = staticCompositionLocalOf { @@ -66,14 +87,21 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun App(viewModel: NostrViewModel) { +fun App( + appViewModel: AppViewModel, + accountViewModel: AccountViewModel, + chatViewModel: ChatViewModel, + profileViewModel: ProfileViewModel, + relayViewModel: RelayViewModel, +) { val context = LocalContext.current val activity = context as? ComponentActivity val backStack = rememberNavBackStack(Screen.Home) val navigator = remember(backStack) { Navigator(backStack) } val qrScanResult = remember { QrScanResult() } - val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle() + // Get the signer required state + val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle() // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -100,7 +128,7 @@ fun App(viewModel: NostrViewModel) { } LaunchedEffect(Unit) { - viewModel.errorEvents.collect { message -> + appViewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -143,7 +171,11 @@ fun App(viewModel: NostrViewModel) { motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalNostrViewModel provides viewModel, + LocalAppViewModel provides appViewModel, + LocalAccountViewModel provides accountViewModel, + LocalChatViewModel provides chatViewModel, + LocalProfileViewModel provides profileViewModel, + LocalRelayViewModel provides relayViewModel, 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 69ac4ad..fd4f8fa 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -10,11 +10,23 @@ 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 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() { @@ -22,16 +34,37 @@ class MainActivity : ComponentActivity() { val externalSignerLauncher = ExternalSignerLauncher() } - private val viewModel: NostrViewModel by viewModels { + private val factory by lazy { object : ViewModelProvider.Factory { + 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) + override fun create(modelClass: Class): T { - val secretStore = SecretStore(this@MainActivity) - val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as 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 + 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 } + override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> throwable.printStackTrace() @@ -69,14 +102,33 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - viewModel.signerRequired.value == null + accountViewModel.signerRequired.value == null } - // Bind the lifecycle of the ViewModel to the Activity's lifecycle' - viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle) + // 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() setContent { - App(viewModel = viewModel) + App( + appViewModel = appViewModel, + accountViewModel = accountViewModel, + chatViewModel = chatViewModel, + profileViewModel = profileViewModel, + relayViewModel = relayViewModel, + ) } } 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 6d42015..793fd9d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -65,8 +65,10 @@ 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.nostr.Room @@ -83,10 +85,12 @@ import su.reya.coop.short fun ChatScreen(id: Long, screening: Boolean = false) { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current // Get chat room by ID - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } // Show empty screen @@ -104,12 +108,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { return } - val displayName by remember(room) { - room?.nameFlow(viewModel) ?: flowOf("Loading...") + val currentUser = accountViewModel.nostr.signer.currentUser + + val displayName by remember(room, currentUser) { + room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...") }.collectAsStateWithLifecycle("Loading...") val picture by remember(room) { - room?.pictureFlow(viewModel) ?: flowOf(null) + room?.pictureFlow(profileViewModel) ?: flowOf(null) }.collectAsStateWithLifecycle(null) var text by remember { mutableStateOf("") } @@ -129,7 +135,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = true // Get messages - val initialMessages = viewModel.getChatRoomMessages(id) + val initialMessages = chatViewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) @@ -137,10 +143,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { loading = false // Get msg relays for each member - viewModel.chatRoomConnect(id) + chatViewModel.chatRoomConnect(id) // Handle new messages - viewModel.newEvents.collect { event -> + chatViewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) @@ -314,7 +320,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { value = text, onValueChange = { text = it }, onSend = { - viewModel.sendMessage(id, text) + chatViewModel.sendMessage(id, text) text = "" } ) @@ -331,14 +337,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.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) { viewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() @@ -348,11 +354,11 @@ fun ScreenerCard(room: Room) { LaunchedEffect(pubkey) { scope.launch { // Check contact - viewModel.verifyContact(pubkey).let { isContact = it } + profileViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts - viewModel.mutualContacts(pubkey).let { mutualContacts = it } + profileViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity - viewModel.verifyActivity(pubkey)?.let { lastActivity = it } + profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it } } } @@ -470,8 +476,9 @@ fun DateSeparator(date: String) { fun ChatMessage( rumor: UnsignedEvent ) { - val viewModel = LocalNostrViewModel.current - val currentUser = viewModel.currentUser() + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val currentUser = accountViewModel.nostr.signer.currentUser val isMine = rumor.author() == currentUser val bubbleShape = if (isMine) { @@ -505,7 +512,7 @@ fun ChatMessage( onClick = { val id = rumor.id() if (id != null) { - val sent = viewModel.isMessageSent(id) + val sent = chatViewModel.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 77bc870..188e25d 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel 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 viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current - val contactList by viewModel.contactList.collectAsStateWithLifecycle() + val contactList by profileViewModel.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 viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.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 = viewModel.addContact(contact) + val success = profileViewModel.addContact(contact) if (success) onDismissRequest() } }) { @@ -283,8 +283,8 @@ fun ContactListItem( total: Int = 0, onClick: () -> Unit, ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -293,7 +293,7 @@ fun ContactListItem( SegmentedListItem( onClick = onClick, - onLongClick = { viewModel.removeContact(pubkey) }, + onLongClick = { profileViewModel.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 ce20113..02341e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -91,8 +91,12 @@ 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalRelayViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -113,7 +117,11 @@ fun HomeScreen() { val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current + val relayViewModel = LocalRelayViewModel.current val scope = rememberCoroutineScope() @@ -121,15 +129,15 @@ fun HomeScreen() { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = viewModel.currentUser() ?: return - val currentUserProfile = viewModel.getMetadata(currentUser) + val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val currentUserProfile = profileViewModel.getMetadata(currentUser) val userProfile by currentUserProfile.collectAsStateWithLifecycle() - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle() - val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() - val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() + 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 expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -157,7 +165,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - viewModel.getChatRooms() + chatViewModel.getChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -165,7 +173,7 @@ fun HomeScreen() { runCatching { PublicKey.parse(result) } .onSuccess { pubkey -> try { - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -281,7 +289,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { viewModel.dismissNotificationBanner() }, + onClick = { appViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -322,7 +330,7 @@ fun HomeScreen() { onRefresh = { scope.launch { isRefreshing = true - viewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, @@ -392,7 +400,7 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = viewModel.currentUser() + val pubkey = accountViewModel.nostr.signer.currentUser val shortPubkey = pubkey?.short() ?: "Not available" val userName = @@ -479,7 +487,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { viewModel.dismissRelayWarning() }, + onDismissRequest = { relayViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -586,7 +594,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - viewModel.refetchMsgRelays(currentUser) + relayViewModel.refetchMsgRelays(currentUser) } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -606,7 +614,7 @@ fun HomeScreen() { enabled = !isBusy, onClick = { scope.launch { - viewModel.useDefaultMsgRelayList() + relayViewModel.useDefaultMsgRelayList() sheetState.hide() } }, @@ -629,18 +637,18 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val profileViewModel = LocalProfileViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) val firstName by remember(firstRoom) { - firstRoom?.nameFlow(viewModel) ?: flowOf("") + firstRoom?.nameFlow(profileViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("Loading...") val secondName by remember(secondRoom) { - secondRoom?.nameFlow(viewModel) ?: flowOf("") + secondRoom?.nameFlow(profileViewModel) ?: flowOf("") }.collectAsStateWithLifecycle("") val supportingText = when { @@ -712,9 +720,9 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val viewModel = LocalNostrViewModel.current - val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null) + val profileViewModel = LocalProfileViewModel.current + val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...") + val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null) ListItem( modifier = Modifier.clickable(onClick = onClick), @@ -760,7 +768,7 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -790,7 +798,7 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { viewModel.logout() } }, + onClick = { onDismiss { accountViewModel.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 bb29514..7bfeaf5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,8 +58,10 @@ 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -74,17 +76,19 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by appViewModel.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(viewModel::getMetadata) ?: flowOf(null) + pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) val profile = metadata?.asRecord() @@ -246,10 +250,10 @@ fun ImportScreen() { onClick = { scope.launch { if (pubkey == null) { - viewModel.verifyIdentity(secret).let { pubkey = it } + accountViewModel.verifyIdentity(secret).let { pubkey = it } } else { // Import the identity - viewModel.importIdentity(secret) + accountViewModel.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 1428253..b62ed30 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -19,16 +19,16 @@ 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 viewModel = LocalNostrViewModel.current - val currentUser = viewModel.currentUser() ?: return + val accountViewModel = LocalAccountViewModel.current + val currentUser = accountViewModel.nostr.signer.currentUser ?: return Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 59b1e16..f2ded0c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_next @@ -54,8 +55,9 @@ 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.LocalProfileViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -69,13 +71,15 @@ fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current - val viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current + + val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + var query by remember { mutableStateOf("") } - val contactList by viewModel.contactList.collectAsState(initial = emptySet()) val createGroup = remember { mutableStateOf(false) } val searchResults = remember { mutableStateListOf() } val selectedReceivers = remember { mutableStateListOf() } - var query by remember { mutableStateOf("") } LaunchedEffect(query) { if (query.length >= 3) { @@ -92,12 +96,12 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = viewModel.searchByAddress(query) + val pubkey = profileViewModel.searchByAddress(query) if (pubkey != null) { selectedReceivers.add(pubkey) } } else { - val results = viewModel.searchByNostr(query) + val results = profileViewModel.searchByNostr(query) searchResults.clear() searchResults.addAll(results) } @@ -167,7 +171,7 @@ fun NewChatScreen() { ) { ExtendedFloatingActionButton( onClick = { - val roomId = viewModel.createChatRoom(selectedReceivers.toList()) + val roomId = chatViewModel.createChatRoom(selectedReceivers.toList()) navigator.navigate(Screen.Chat(roomId)) }, expanded = false, @@ -258,7 +262,7 @@ fun NewChatScreen() { items = searchResults, selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) }, ) @@ -269,7 +273,7 @@ fun NewChatScreen() { items = contactList.toList(), selectedReceivers = selectedReceivers, onContactClick = { pubkey -> - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } ) @@ -286,8 +290,8 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsState(initial = null) val profile = metadata?.asRecord() @@ -372,8 +376,8 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val viewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val profileViewModel = LocalProfileViewModel.current + val metadataFlow = remember(pubkey) { profileViewModel.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 216e402..88eca83 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -5,17 +5,21 @@ 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) ProfileEditor( title = "Create a new identity", @@ -24,7 +28,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - viewModel.createIdentity(name, bio, bytes, type) + accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel) 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 cb0ca6e..90a34c3 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 viewModel = LocalNostrViewModel.current + val accountViewModel = LocalAccountViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) @@ -158,9 +158,9 @@ fun OnboardingScreen() { FilledTonalButton( onClick = { scope.launch { - if (viewModel.isExternalSignerAvailable()) { + if (accountViewModel.isExternalSignerAvailable()) { try { - viewModel.connectExternalSigner() + accountViewModel.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 87ffd06..edb46af 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -44,8 +44,9 @@ 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.LocalProfileViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -58,12 +59,13 @@ fun ProfileScreen(pubkey: String) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current + val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } val metadata by metadataFlow.collectAsStateWithLifecycle() val profile = metadata?.asRecord() @@ -159,7 +161,7 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = viewModel.createChatRoom(listOf(pubkey)) + val roomId = chatViewModel.createChatRoom(listOf(pubkey)) 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/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 8c23271..959f671 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.LocalNostrViewModel +import su.reya.coop.LocalRelayViewModel 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 viewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current val scope = rememberCoroutineScope() val msgRelayList = remember { mutableStateListOf() } @@ -96,8 +96,8 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayList.putAll(viewModel.currentUserRelayList()) - msgRelayList.addAll(viewModel.currentUserMsgRelayList()) + relayList.putAll(relayViewModel.currentUserRelayList()) + msgRelayList.addAll(relayViewModel.currentUserMsgRelayList()) } Scaffold( @@ -321,11 +321,11 @@ fun RelayScreen() { return@launch } try { - viewModel.removeMsgRelay(relayToDelete!!) + relayViewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } catch (e: Exception) { - snackbarHostState.showSnackbar("Failed to remove relay") + snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}") } } } @@ -349,7 +349,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val viewModel = LocalNostrViewModel.current + val relayViewModel = LocalRelayViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -401,17 +401,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - viewModel.addMsgRelay(relayAddress) + relayViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - viewModel.addInboxRelay(relayAddress) + relayViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - viewModel.addOutboxRelay(relayAddress) + relayViewModel.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 a902365..53d6d73 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 viewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() var isRefreshing by remember { mutableStateOf(false) } - val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() + val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() // Get all request rooms val requests = remember(chatRooms) { @@ -103,7 +103,7 @@ fun RequestListScreen() { onRefresh = { scope.launch { isRefreshing = true - viewModel.refreshChatRooms() + chatViewModel.refreshChatRooms() isRefreshing = false } }, 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 276fb4c..35d4a0b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -1,24 +1,27 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState 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.LocalNostrViewModel +import su.reya.coop.LocalProfileViewModel import su.reya.coop.shared.ProfileEditor @Composable fun UpdateProfileScreen() { - val viewModel = LocalNostrViewModel.current + val appViewModel = LocalAppViewModel.current + val accountViewModel = LocalAccountViewModel.current + val profileViewModel = LocalProfileViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val currentUser = viewModel.currentUser() ?: return - val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null) - val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false) + val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() + val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) val profile = metadata?.asRecord() @@ -32,7 +35,7 @@ fun UpdateProfileScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - viewModel.updateProfile(name, bio, bytes, type) + profileViewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } } 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 8fdbff4..7184393 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -4,18 +4,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import su.reya.coop.NostrViewModel +import rust.nostr.sdk.PublicKey import su.reya.coop.nostr.Room import su.reya.coop.short +import su.reya.coop.viewmodels.ProfileViewModel -fun Room.nameFlow(viewModel: NostrViewModel): Flow { +fun Room.nameFlow( + profileViewModel: ProfileViewModel, + 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 { viewModel.getMetadata(it) }) { metadataArray -> + return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray -> val names = metadataArray.mapIndexed { i, metadata -> val profile = metadata?.asRecord() profile?.displayName?.takeIf { it.isNotBlank() } @@ -29,12 +33,12 @@ fun Room.nameFlow(viewModel: NostrViewModel): Flow { if (extraCount > 0) "$combined, +$extraCount" else combined } else { val name = names.first() - if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name + if (displayMembers.first() == currentUser) "$name (you)" else name } } } -fun Room.pictureFlow(viewModel: NostrViewModel): Flow { +fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow { val firstMember = members.firstOrNull() ?: return flowOf(null) - return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } -} \ No newline at end of file + return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt new file mode 100644 index 0000000..3c5d624 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt @@ -0,0 +1,8 @@ +package su.reya.coop + +import rust.nostr.sdk.PublicKey + +fun PublicKey.short(): String { + val bech32 = toBech32() + return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt deleted file mode 100644 index 632f713..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ /dev/null @@ -1,905 +0,0 @@ -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( - private 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") - - // 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 - } - - private suspend fun blossomUpload(file: ByteArray, contentType: String): 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}") - } - } - } - - 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() - } - } -} - -fun PublicKey.short(): String { - val bech32 = toBech32() - return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) -} 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 65fa513..bfeef8a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -63,4 +63,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { return get().nip44DecryptAsync(publicKey, payload) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt new file mode 100644 index 0000000..e8b8d2e --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt @@ -0,0 +1,210 @@ +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", + profileViewModel: ProfileViewModel + ) { + appViewModel.setBusy(true) + + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + + try { + val avatarUrl = picture?.let { profileViewModel.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 new file mode 100644 index 0000000..ae383bf --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt @@ -0,0 +1,64 @@ +package su.reya.coop.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 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 + } + } + + 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 new file mode 100644 index 0000000..74dc5b9 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt @@ -0,0 +1,199 @@ +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}") + } + } + } + + 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 new file mode 100644 index 0000000..b015e22 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt @@ -0,0 +1,248 @@ +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.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 kotlinx.serialization.json.Json +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Timestamp +import su.reya.coop.blossom.BlossomClient +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 blossomUpload(file: ByteArray, contentType: String?): 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) { + appViewModel.showError("Error: ${e.message}") + return null + } + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + appViewModel.setBusy(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") + 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 new file mode 100644 index 0000000..b779ef9 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt @@ -0,0 +1,149 @@ +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}") + } + } +}