From a2d28ecc4010f197e15125329f16c39f14f5970c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 3 Jul 2026 01:20:36 +0000 Subject: [PATCH] chore: refactor the internal structure (#33) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/33 --- composeApp/build.gradle.kts | 4 +- .../androidMain/kotlin/su/reya/coop/App.kt | 43 +- .../kotlin/su/reya/coop/MainActivity.kt | 69 +-- .../su/reya/coop/NostrForegroundService.kt | 1 - .../kotlin/su/reya/coop/screens/ChatScreen.kt | 81 ++-- .../su/reya/coop/screens/ContactListScreen.kt | 28 +- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 113 ++--- .../su/reya/coop/screens/ImportScreen.kt | 31 +- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 10 +- .../su/reya/coop/screens/NewChatScreen.kt | 42 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 14 +- .../su/reya/coop/screens/OnboardingScreen.kt | 10 +- .../su/reya/coop/screens/ProfileScreen.kt | 32 +- .../su/reya/coop/screens/RelayScreen.kt | 18 +- .../su/reya/coop/screens/RequestListScreen.kt | 4 +- .../reya/coop/screens/UpdateProfileScreen.kt | 20 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 44 -- gradle/libs.versions.toml | 6 +- shared/build.gradle.kts | 6 +- .../commonMain/kotlin/su/reya/coop/Profile.kt | 20 + .../kotlin/su/reya/coop/{nostr => }/Room.kt | 65 ++- .../kotlin/su/reya/coop/nostr/Messaging.kt | 7 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 6 +- .../su/reya/coop/nostr/ProfileManager.kt | 21 +- .../kotlin/su/reya/coop/nostr/RelayManager.kt | 3 +- .../kotlin/su/reya/coop/nostr/Signer.kt | 9 +- .../reya/coop/repository/ErrorRepository.kt | 13 + .../reya/coop/repository/MediaRepository.kt | 39 ++ .../AuthViewModel.kt} | 197 +++++--- .../su/reya/coop/viewmodel/BaseViewModel.kt | 10 + .../su/reya/coop/viewmodel/ChatViewModel.kt | 260 ++++++++++ .../su/reya/coop/viewmodel/NostrViewModel.kt | 446 ++++++++++++++++++ .../su/reya/coop/viewmodels/AppViewModel.kt | 92 ---- .../su/reya/coop/viewmodels/ChatViewModel.kt | 221 --------- .../reya/coop/viewmodels/ProfileViewModel.kt | 216 --------- .../su/reya/coop/viewmodels/RelayViewModel.kt | 149 ------ 36 files changed, 1211 insertions(+), 1139 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Profile.kt rename shared/src/commonMain/kotlin/su/reya/coop/{nostr => }/Room.kt (73%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt rename shared/src/commonMain/kotlin/su/reya/coop/{viewmodels/AccountViewModel.kt => viewmodel/AuthViewModel.kt} (53%) create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt delete mode 100644 shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c822be5..1e7d78a 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,8 +25,8 @@ kotlin { implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.androidx.core.splashscreen) implementation("su.reya:nostr-sdk-kmp:0.3.2") - implementation("io.coil-kt.coil3:coil-compose:3.4.0") - implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + implementation("io.coil-kt.coil3:coil-compose:3.5.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.alexzhirkevich:qrose:1.1.2") } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1f06149..8d242ce 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -34,6 +34,7 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import su.reya.coop.repository.ErrorRepository import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen @@ -47,30 +48,20 @@ 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 +import su.reya.coop.viewmodel.AuthViewModel +import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.NostrViewModel -val LocalAppViewModel = staticCompositionLocalOf { - error("No AppViewModel provided") -} - -val LocalAccountViewModel = staticCompositionLocalOf { - error("No AccountViewModel provided") +val LocalNostrViewModel = staticCompositionLocalOf { + error("No NostrViewModel provided") } val LocalChatViewModel = staticCompositionLocalOf { error("No ChatViewModel provided") } -val LocalProfileViewModel = staticCompositionLocalOf { - error("No ProfileViewModel provided") -} - -val LocalRelayViewModel = staticCompositionLocalOf { - error("No RelayViewModel provided") +val LocalAuthViewModel = staticCompositionLocalOf { + error("No AuthViewModel provided") } val LocalSnackbarHostState = staticCompositionLocalOf { @@ -88,11 +79,9 @@ val LocalScanResult = staticCompositionLocalOf { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun App( - appViewModel: AppViewModel, - accountViewModel: AccountViewModel, + nostrViewModel: NostrViewModel, chatViewModel: ChatViewModel, - profileViewModel: ProfileViewModel, - relayViewModel: RelayViewModel, + authViewModel: AuthViewModel, ) { val context = LocalContext.current val activity = context as? ComponentActivity @@ -101,7 +90,8 @@ fun App( val qrScanResult = remember { QrScanResult() } // Get the signer required state - val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle() + val authState by authViewModel.state.collectAsStateWithLifecycle() + val signerRequired = authState.signerRequired // Snackbar val snackbarHostState = remember { SnackbarHostState() } @@ -128,7 +118,7 @@ fun App( } LaunchedEffect(Unit) { - appViewModel.errorEvents.collect { message -> + ErrorRepository.errors.collect { message -> snackbarHostState.showSnackbar(message) } } @@ -171,15 +161,14 @@ fun App( motionScheme = MotionScheme.expressive(), ) { CompositionLocalProvider( - LocalAppViewModel provides appViewModel, - LocalAccountViewModel provides accountViewModel, + LocalNostrViewModel provides nostrViewModel, LocalChatViewModel provides chatViewModel, - LocalProfileViewModel provides profileViewModel, - LocalRelayViewModel provides relayViewModel, + LocalAuthViewModel provides authViewModel, LocalSnackbarHostState provides snackbarHostState, LocalNavigator provides navigator, LocalScanResult provides qrScanResult, ) { + NavDisplay( backStack = backStack, onBack = { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index fd4f8fa..ec77b12 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -10,23 +10,13 @@ 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 su.reya.coop.viewmodel.AuthViewModel +import su.reya.coop.viewmodel.ChatViewModel +import su.reya.coop.viewmodel.NostrViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { @@ -36,34 +26,30 @@ class MainActivity : ComponentActivity() { private val factory by lazy { object : ViewModelProvider.Factory { - private val secretStore = SecretStore(this@MainActivity) private val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) - private val appVM = AppViewModel(NostrManager.instance, secretStore) - private val profileVM = ProfileViewModel(NostrManager.instance, appVM) - private val chatVM = ChatViewModel(NostrManager.instance, appVM) - private val accountVM = - AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner) - private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM) + private val secretStore = SecretStore(this@MainActivity) + private val nostrViewModel = + NostrViewModel(NostrManager.instance) + private val chatViewModel = + ChatViewModel(NostrManager.instance) + private val authViewModel = + AuthViewModel(NostrManager.instance, secretStore, androidSigner) override fun create(modelClass: Class): T { return when { - modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM - modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM - modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM - modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM - modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM + modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel + modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel + modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel else -> throw IllegalArgumentException("Unknown ViewModel class") } as T } } } - private val appViewModel: AppViewModel by viewModels { factory } - private val accountViewModel: AccountViewModel by viewModels { factory } + private val nostrViewModel: NostrViewModel by viewModels { factory } private val chatViewModel: ChatViewModel by viewModels { factory } - private val profileViewModel: ProfileViewModel by viewModels { factory } - private val relayViewModel: RelayViewModel by viewModels { factory } + private val authViewModel: AuthViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -102,36 +88,19 @@ class MainActivity : ComponentActivity() { // Keep the splash screen visible until the signer check is complete splashScreen.setKeepOnScreenCondition { - accountViewModel.signerRequired.value == null + authViewModel.state.value.signerRequired == null } - // Bind the lifecycle of the ViewModels - val lifecycle = ProcessLifecycleOwner.get().lifecycle - lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - appViewModel.viewModelScope.launch { - owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - coroutineScope { - launch { chatViewModel.bindObservers() } - launch { profileViewModel.bindObservers() } - } - } - } - } - }) - relayViewModel.observeSignerAndCheckRelays() - setContent { App( - appViewModel = appViewModel, - accountViewModel = accountViewModel, + nostrViewModel = nostrViewModel, chatViewModel = chatViewModel, - profileViewModel = profileViewModel, - relayViewModel = relayViewModel, + authViewModel = authViewModel, ) } } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index a604a0e..76ebe5b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import su.reya.coop.nostr.NostrManager -import su.reya.coop.nostr.roomId import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 6df1cae..c53a642 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -70,27 +70,24 @@ import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.formatAsGroupHeader -import su.reya.coop.nostr.humanReadable -import su.reya.coop.nostr.roomId +import su.reya.coop.formatAsGroupHeader +import su.reya.coop.humanReadable +import su.reya.coop.rememberUiState +import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -99,11 +96,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current + val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() + // Get current user + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + // Get chat room by ID val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } @@ -123,15 +123,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { return } - - val displayName by remember(room) { - room?.nameFlow(profileViewModel) ?: flowOf("Loading...") - }.collectAsStateWithLifecycle("Loading...") - - val picture by remember(room) { - room?.pictureFlow(profileViewModel) ?: flowOf(null) - }.collectAsStateWithLifecycle(null) - + val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } @@ -215,14 +207,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LoadingIndicator(modifier = Modifier.size(32.dp)) } else { Avatar( - picture = picture, - description = displayName, + picture = roomState.picture, + description = roomState.name, size = 32.dp, ) } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, ) } @@ -282,7 +274,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { items = messagesInGroup, key = { it.id()?.toBech32() ?: it.hashCode() } ) { - ChatMessage(it) + ChatMessage( + rumor = it, + isMine = currentUser?.publicKey == it.author() + ) } item { DateSeparator(dateHeader) @@ -377,28 +372,24 @@ fun ChatScreen(id: Long, screening: Boolean = false) { fun ScreenerCard(room: Room) { val pubkey = room.members.firstOrNull() ?: return - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() var isContact by remember { mutableStateOf(false) } var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsStateWithLifecycle() - - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { scope.launch { // Check contact - profileViewModel.verifyContact(pubkey).let { isContact = it } + nostrViewModel.verifyContact(pubkey).let { isContact = it } // Get mutual contacts - profileViewModel.mutualContacts(pubkey).let { mutualContacts = it } + nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } // Get the last activity - profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it } + nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } } } @@ -415,7 +406,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.size(120.dp), shape = MaterialShapes.Cookie12Sided.toShape(), @@ -425,7 +416,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -514,13 +505,9 @@ fun DateSeparator(date: String) { @Composable fun ChatMessage( - rumor: UnsignedEvent + rumor: UnsignedEvent, + isMine: Boolean = false, ) { - val accountViewModel = LocalAccountViewModel.current - val chatViewModel = LocalChatViewModel.current - val currentUser = accountViewModel.nostr.signer.currentUser - val isMine = rumor.author() == currentUser - val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) } else { @@ -546,17 +533,7 @@ fun ChatMessage( color = containerColor, contentColor = contentColor, shape = bubbleShape, - modifier = Modifier - .widthIn(max = 280.dp) - .clickable( - onClick = { - val id = rumor.id() - if (id != null) { - val sent = chatViewModel.isMessageSent(id) - println("Sent: $sent") - } - } - ) + modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = rumor.content(), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 188e25d..e8c6cbf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -55,7 +55,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Nip05Address import rust.nostr.sdk.PublicKey import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -66,9 +66,9 @@ import su.reya.coop.short fun ContactListScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current - val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var openAddContactDialog by remember { mutableStateOf(false) } Scaffold( @@ -180,7 +180,7 @@ fun ContactListScreen() { @Composable fun AddContactDialog(onDismissRequest: () -> Unit) { val snackbarHostState = LocalSnackbarHostState.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val focusRequester = remember { FocusRequester() } var contact by remember { mutableStateOf("") } var isError by remember { mutableStateOf(false) } @@ -225,7 +225,7 @@ fun AddContactDialog(onDismissRequest: () -> Unit) { actions = { IconButton(onClick = { scope.launch { - val success = profileViewModel.addContact(contact) + val success = nostrViewModel.addContact(contact) if (success) onDismissRequest() } }) { @@ -283,32 +283,28 @@ fun ContactListItem( total: Int = 0, onClick: () -> Unit, ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val nostrViewModel = LocalNostrViewModel.current + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( onClick = onClick, - onLongClick = { profileViewModel.removeContact(pubkey) }, + onLongClick = { nostrViewModel.removeContact(pubkey) }, shapes = ListItemDefaults.segmentedShapes( index = index, count = total ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name, size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 02341e8..d0283e3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -60,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -87,27 +86,22 @@ import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel +import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel -import su.reya.coop.LocalRelayViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.RoomKind -import su.reya.coop.nostr.ago +import su.reya.coop.ago +import su.reya.coop.rememberUiState import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -117,27 +111,24 @@ fun HomeScreen() { val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current + val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current - val relayViewModel = LocalRelayViewModel.current - + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(true) val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = accountViewModel.nostr.signer.currentUser ?: return - val currentUserProfile = profileViewModel.getMetadata(currentUser) - - val userProfile by currentUserProfile.collectAsStateWithLifecycle() + val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() - val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle() + + val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() - val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState() + + val authState by authViewModel.state.collectAsStateWithLifecycle() + val isBannerDismissed = authState.isNotificationBannerDismissed val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } @@ -165,7 +156,7 @@ fun HomeScreen() { } LaunchedEffect(Unit) { - chatViewModel.getChatRooms() + chatViewModel.refreshChatRooms() } LaunchedEffect(qrScanResult.content) { @@ -218,8 +209,8 @@ fun HomeScreen() { // User IconButton(onClick = { showBottomSheet = true }) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", size = 32.dp, ) } @@ -289,7 +280,7 @@ fun HomeScreen() { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton( - onClick = { appViewModel.dismissNotificationBanner() }, + onClick = { authViewModel.dismissNotificationBanner() }, modifier = Modifier.weight(1f), ) { Text(text = "Maybe later") @@ -400,14 +391,6 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = accountViewModel.nostr.signer.currentUser - val shortPubkey = pubkey?.short() ?: "Not available" - - val userName = - userProfile?.asRecord()?.displayName - ?: userProfile?.asRecord()?.name - ?: "No name" - val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> scope.launch { sheetState.hide() @@ -432,8 +415,8 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", shape = MaterialShapes.Cookie9Sided.toShape(), modifier = Modifier.fillMaxSize() ) @@ -443,7 +426,7 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Text( - text = userName, + text = userProfile?.name ?: "No name", style = MaterialTheme.typography.titleLargeEmphasized, ) } @@ -454,16 +437,15 @@ fun HomeScreen() { OutlinedButton( onClick = { scope.launch { - pubkey?.let { + userProfile?.publicKey?.let { val bech32 = it.toBech32() - val data = - ClipData.newPlainText(bech32, bech32) + val data = ClipData.newPlainText(bech32, bech32) clipboardManager.setClipEntry(ClipEntry(data)) } } }, ) { - Text(text = shortPubkey) + Text(text = userProfile?.shortPublicKey ?: "Unknown") } FilledIconButton( onClick = { @@ -487,7 +469,7 @@ fun HomeScreen() { // Show the relay setup dialog if the msg relay list is empty if (isRelayListEmpty) { ModalBottomSheet( - onDismissRequest = { relayViewModel.dismissRelayWarning() }, + onDismissRequest = { nostrViewModel.dismissRelayWarning() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { @@ -594,7 +576,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - relayViewModel.refetchMsgRelays(currentUser) + nostrViewModel.refetchMsgRelays() } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -614,7 +596,7 @@ fun HomeScreen() { enabled = !isBusy, onClick = { scope.launch { - relayViewModel.useDefaultMsgRelayList() + nostrViewModel.useDefaultMsgRelayList() sheetState.hide() } }, @@ -637,34 +619,29 @@ fun HomeScreen() { @Composable fun NewRequests(requests: List) { val navigator = LocalNavigator.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val total = requests.size val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstName by remember(firstRoom) { - firstRoom?.nameFlow(profileViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("Loading...") - - val secondName by remember(secondRoom) { - secondRoom?.nameFlow(profileViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("") + val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) + val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) val supportingText = when { - total == 1 && firstRoom != null -> { + total == 1 -> { val message = firstRoom.lastMessage ?: "" - "$firstName: $message" + "${firstRoomState.name}: $message" } total == 2 -> { - "$firstName and $secondName" + "${firstRoomState.name} and ${secondRoomState.name}" } total > 2 -> { val othersCount = total - 2 val othersText = if (othersCount == 1) "1 other" else "$othersCount others" - "$firstName, $secondName and $othersText" + "${firstRoomState.name}, ${secondRoomState.name} and $othersText" } else -> "" @@ -720,14 +697,13 @@ fun NewRequests(requests: List) { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { - val profileViewModel = LocalProfileViewModel.current - val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null) + val nostrViewModel = LocalNostrViewModel.current + val roomState by room.rememberUiState(nostrViewModel) ListItem( modifier = Modifier.clickable(onClick = onClick), leadingContent = { - Avatar(picture = picture, description = displayName) + Avatar(picture = roomState.picture, description = roomState.picture) }, headlineContent = { Row( @@ -735,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f) ) @@ -768,7 +744,9 @@ fun BottomMenuList( onDismiss: (suspend () -> Unit) -> Unit ) { val navigator = LocalNavigator.current - val accountViewModel = LocalAccountViewModel.current + val nostrViewModel = LocalNostrViewModel.current + val chatViewModel = LocalChatViewModel.current + val authViewModel = LocalAuthViewModel.current val defaultMenuList = listOf( "Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, @@ -798,7 +776,14 @@ fun BottomMenuList( } Spacer(modifier = Modifier.size(16.dp)) FilledTonalButton( - onClick = { onDismiss { accountViewModel.logout() } }, + onClick = { + onDismiss { + authViewModel.logout(onLogout = { + nostrViewModel.resetInternalState() + chatViewModel.resetInternalState() + }) + } + }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 7bfeaf5..aea7ee5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -58,16 +58,14 @@ 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.LocalAuthViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -76,25 +74,20 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) + val authState by authViewModel.state.collectAsStateWithLifecycle() + val isBusy = authState.isBusy var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } - // Get metadata when pubkey changes - val metadata by remember(pubkey) { - pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null) + val profile by remember(pubkey) { + pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" - val picture = profile?.picture - LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { @@ -168,7 +161,7 @@ fun ImportScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.fillMaxSize(), shape = MaterialShapes.Cookie9Sided.toShape(), @@ -176,7 +169,7 @@ fun ImportScreen() { } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = profile?.name ?: "", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -250,10 +243,10 @@ fun ImportScreen() { onClick = { scope.launch { if (pubkey == null) { - accountViewModel.verifyIdentity(secret).let { pubkey = it } + authViewModel.verifyIdentity(secret).let { pubkey = it } } else { // Import the identity - accountViewModel.importIdentity(secret) + authViewModel.importIdentity(secret) // Navigate to the home screen navigator.navigate(Screen.Home) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index b62ed30..505e806 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -13,22 +13,24 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAccountViewModel import su.reya.coop.LocalNavigator +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @Composable fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val accountViewModel = LocalAccountViewModel.current - val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val nostrViewModel = LocalNostrViewModel.current + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -59,7 +61,7 @@ fun MyQrScreen() { horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = rememberQrCodePainter(currentUser.toBech32()), + painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""), contentDescription = "My QR" ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index f2ded0c..2c2fb10 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -57,7 +57,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -71,10 +71,10 @@ fun NewChatScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current + val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current - val contactList by profileViewModel.contactList.collectAsStateWithLifecycle() + val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle() var query by remember { mutableStateOf("") } val createGroup = remember { mutableStateOf(false) } @@ -96,12 +96,12 @@ fun NewChatScreen() { selectedReceivers.add(pubkey) } } else if (query.contains("@")) { - val pubkey = profileViewModel.searchByAddress(query) + val pubkey = nostrViewModel.searchByAddress(query) if (pubkey != null) { selectedReceivers.add(pubkey) } } else { - val results = profileViewModel.searchByNostr(query) + val results = nostrViewModel.searchByNostr(query) searchResults.clear() searchResults.addAll(results) } @@ -290,13 +290,9 @@ fun ReceiverChip( pubkey: PublicKey, onRemove: () -> Unit ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val nostrViewModel = LocalNostrViewModel.current + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { InputChip( @@ -304,7 +300,7 @@ fun ReceiverChip( onClick = onRemove, label = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.SemiBold ) @@ -312,8 +308,8 @@ fun ReceiverChip( }, avatar = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "No name", size = 24.dp ) }, @@ -376,13 +372,9 @@ fun ContactListItem( onClick: () -> Unit, onLongClick: () -> Unit ) { - val profileViewModel = LocalProfileViewModel.current - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val nostrViewModel = LocalNostrViewModel.current + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( selected = isSelected, @@ -394,15 +386,15 @@ fun ContactListItem( ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "", size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 4b98253..758d75d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -5,21 +5,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel +import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel import su.reya.coop.Screen import su.reya.coop.shared.ProfileEditor @Composable fun NewIdentityScreen() { - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val authViewModel = LocalAuthViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) + + val authState by authViewModel.state.collectAsStateWithLifecycle() + val isBusy = authState.isBusy ProfileEditor( title = "Create a new identity", @@ -28,7 +26,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - accountViewModel.createIdentity(name, bio, bytes, type) + authViewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index 90a34c3..bbdf7d6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -45,7 +45,7 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.coop import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource -import su.reya.coop.LocalAccountViewModel +import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen @@ -57,12 +57,12 @@ fun OnboardingScreen() { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current - val accountViewModel = LocalAccountViewModel.current + val authViewModel = LocalAuthViewModel.current val scope = rememberCoroutineScope() val logoPainter = painterResource(Res.drawable.coop) val expressiveFont = getExpressiveFontFamily() - + val annotatedText = buildAnnotatedString { append("By using Coop, you agree to accept\nour ") // Push "Terms of Use" link @@ -158,9 +158,9 @@ fun OnboardingScreen() { FilledTonalButton( onClick = { scope.launch { - if (accountViewModel.isExternalSignerAvailable()) { + if (authViewModel.isExternalSignerAvailable()) { try { - accountViewModel.connectExternalSigner() + authViewModel.connectExternalSigner() navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index edb46af..35cd311 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -46,7 +46,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar @@ -56,28 +56,27 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ProfileScreen(pubkey: String) { + val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current + val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current - val profileViewModel = LocalProfileViewModel.current val scope = rememberCoroutineScope() - val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsStateWithLifecycle() - val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsStateWithLifecycle() - - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val nip05 = profile?.nip05 ?: pubkey.short() - val picture = profile?.picture + val metadata = profile?.metadata?.asRecord() + val nip05 = metadata?.nip05 ?: pubkey.short() + val picture = metadata?.picture val details = remember(profile) { listOf( - "Username:" to (profile?.name ?: "None"), - "Website:" to (profile?.website ?: "None"), - "₿ Lightning Address:" to (profile?.lud16 ?: "None"), + "Username:" to (metadata?.name ?: "None"), + "Website:" to (metadata?.website ?: "None"), + "₿ Lightning Address:" to (metadata?.lud16 ?: "None"), ) } @@ -135,7 +134,7 @@ fun ProfileScreen(pubkey: String) { verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -161,7 +160,8 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = chatViewModel.createChatRoom(listOf(pubkey)) + val roomId = + chatViewModel.createChatRoom(listOf(pubkey)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } @@ -240,4 +240,4 @@ fun ProfileScreen(pubkey: String) { } } ) -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt index 959f671..37515e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RelayScreen.kt @@ -66,7 +66,7 @@ import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayUrl import su.reya.coop.LocalNavigator -import su.reya.coop.LocalRelayViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState fun RelayScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val relayViewModel = LocalRelayViewModel.current + val nostrViewModel = LocalNostrViewModel.current val scope = rememberCoroutineScope() val msgRelayList = remember { mutableStateListOf() } @@ -96,8 +96,8 @@ fun RelayScreen() { var relayToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - relayList.putAll(relayViewModel.currentUserRelayList()) - msgRelayList.addAll(relayViewModel.currentUserMsgRelayList()) + relayList.putAll(nostrViewModel.currentUserRelayList()) + msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList()) } Scaffold( @@ -321,7 +321,7 @@ fun RelayScreen() { return@launch } try { - relayViewModel.removeMsgRelay(relayToDelete!!) + nostrViewModel.removeMsgRelay(relayToDelete!!) msgRelayList.removeIf { it.toString() == relayToDelete } relayToDelete = null } catch (e: Exception) { @@ -349,7 +349,7 @@ fun AddRelayDialog( onMsgRelayAdded: (newRelay: String) -> Unit, onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit, ) { - val relayViewModel = LocalRelayViewModel.current + val nostrViewModel = LocalNostrViewModel.current val snackbarHostState = LocalSnackbarHostState.current val scope = rememberCoroutineScope() @@ -401,17 +401,17 @@ fun AddRelayDialog( if (!isError) { when (selected) { "Messaging" -> { - relayViewModel.addMsgRelay(relayAddress) + nostrViewModel.addMsgRelay(relayAddress) onMsgRelayAdded(relayAddress) } "Inbox" -> { - relayViewModel.addInboxRelay(relayAddress) + nostrViewModel.addInboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.WRITE) } "Outbox" -> { - relayViewModel.addOutboxRelay(relayAddress) + nostrViewModel.addOutboxRelay(relayAddress) onRelayAdded(relayAddress, RelayMetadata.READ) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index 53d6d73..4580f5f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.RoomKind @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -155,4 +155,4 @@ fun RequestListScreen() { } } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index 35d4a0b..074a9b7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -5,25 +5,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import su.reya.coop.LocalAccountViewModel -import su.reya.coop.LocalAppViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalProfileViewModel +import su.reya.coop.LocalNostrViewModel import su.reya.coop.shared.ProfileEditor @Composable fun UpdateProfileScreen() { - val appViewModel = LocalAppViewModel.current - val accountViewModel = LocalAccountViewModel.current - val profileViewModel = LocalProfileViewModel.current + val nostrViewModel = LocalNostrViewModel.current val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val currentUser = accountViewModel.nostr.signer.currentUser ?: return - val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() - val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false) - - val profile = metadata?.asRecord() + val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val profile = currentUser?.metadata?.asRecord() ProfileEditor( title = "Update profile", @@ -35,9 +29,9 @@ fun UpdateProfileScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - profileViewModel.updateProfile(name, bio, bytes, type) + nostrViewModel.updateProfile(name, bio, bytes, type) navigator.goBack() } } ) -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt deleted file mode 100644 index 7184393..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ /dev/null @@ -1,44 +0,0 @@ -package su.reya.coop.shared - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import rust.nostr.sdk.PublicKey -import su.reya.coop.nostr.Room -import su.reya.coop.short -import su.reya.coop.viewmodels.ProfileViewModel - -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 { profileViewModel.getMetadata(it) }) { metadataArray -> - val names = metadataArray.mapIndexed { i, metadata -> - val profile = metadata?.asRecord() - profile?.displayName?.takeIf { it.isNotBlank() } - ?: profile?.name?.takeIf { it.isNotBlank() } - ?: displayMembers[i].short() - } - - if (isGroup()) { - val combined = names.joinToString(", ") - val extraCount = members.size - names.size - if (extraCount > 0) "$combined, +$extraCount" else combined - } else { - val name = names.first() - if (displayMembers.first() == currentUser) "$name (you)" else name - } - } -} - -fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow { - val firstMember = members.firstOrNull() ?: return flowOf(null) - return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56eb663..59fef3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,14 +10,14 @@ androidx-espresso = "3.7.0" androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" androidx-splashscreen = "1.2.0" -composeMultiplatform = "1.11.0" +composeMultiplatform = "1.11.1" datastorePreferences = "1.2.1" junit = "4.13.2" -kotlin = "2.3.21" +kotlin = "2.4.0" kotlinx-serialization = "1.11.0" material3 = "1.11.0-alpha07" multiplatform-nav3-ui = "1.1.1" -ktor = "3.5.0" +ktor = "3.5.1" [libraries] androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b9d19bd..276ec31 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) kotlin("plugin.serialization") version libs.versions.kotlin.get() } @@ -31,10 +33,10 @@ kotlin { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("su.reya:nostr-sdk-kmp:0.3.2") - implementation("com.squareup.okio:okio:3.16.2") + implementation("com.squareup.okio:okio:3.17.0") } androidMain.dependencies { implementation(libs.ktor.client.okhttp) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt new file mode 100644 index 0000000..8b40cd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt @@ -0,0 +1,20 @@ +package su.reya.coop + +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.PublicKey + +data class Profile( + val publicKey: PublicKey, + val metadata: Metadata +) { + private val record by lazy { metadata.asRecord() } + + val name: String + get() = record.displayName ?: record.name ?: publicKey.short() + + val picture: String? + get() = record.picture + + val shortPublicKey: String + get() = publicKey.short() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt similarity index 73% rename from shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt rename to shared/src/commonMain/kotlin/su/reya/coop/Room.kt index eed2744..b1c6c3e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,5 +1,12 @@ -package su.reya.coop.nostr +package su.reya.coop +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus @@ -8,6 +15,7 @@ import kotlinx.datetime.toLocalDateTime import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.viewmodel.NostrViewModel import kotlin.time.Clock import kotlin.time.Instant @@ -75,9 +83,60 @@ data class Room( return this.copy(lastMessage = message) } - fun isGroup(): Boolean { - return members.size > 1 + fun isGroup(): Boolean = members.size > 1 +} + +data class RoomUiState( + val name: String = "Loading...", + val picture: String? = null, + val isGroup: Boolean = false +) + +fun Room.uiStateFlow( + nostrViewModel: NostrViewModel, + currentUser: PublicKey? = null +): Flow { + val displayMembers = if (isGroup()) members.take(2) else members.take(1) + + if (!subject.isNullOrBlank()) { + return flowOf(RoomUiState(name = subject, isGroup = isGroup())) } + + return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> + val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() } + + val name = when { + isGroup() -> { + val combined = names.joinToString(", ") + val extra = members.size - names.size + if (extra > 0) "$combined, +$extra" else combined + } + + else -> { + val first = names.firstOrNull() ?: "Unknown" + if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first + } + } + + RoomUiState( + name = name, + picture = profiles.firstOrNull()?.picture, + isGroup = isGroup() + ) + } +} + +@Composable +fun Room.rememberUiState( + viewModel: NostrViewModel, + currentUser: PublicKey? = null +): State { + return remember(this, currentUser) { + uiStateFlow( + viewModel, + currentUser + ) + }.collectAsStateWithLifecycle(RoomUiState()) } fun UnsignedEvent.roomId(): Long { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 157f200..145b728 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync +import su.reya.coop.Room +import su.reya.coop.RoomKind +import su.reya.coop.roomId import kotlin.time.Duration data class MessageSyncState( @@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) { suspend fun getUserMessages(msgRelayList: Event) { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) // Ensure relay connections diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index b873b7a..4483ba0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -79,6 +79,8 @@ class Nostr { // Initialize configurations for nostr client val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() + + // Initialize the authenticator val authenticator = SignerAuthenticator(signer) val idleTimeout = Duration.parse("5m") @@ -136,7 +138,7 @@ class Nostr { fun isSignedByUser(event: Event): Boolean { return try { - signer.currentUser == event.author() + signer.publicKeyFlow.value == event.author() } catch (e: Exception) { println("Failed to check if event is signed by user: ${e.message}") false @@ -208,7 +210,7 @@ class Nostr { if (isSignedByUser(event = event)) { val pubkeys = event.tags().publicKeys() // Get mutual contacts - profiles.getMutualContacts(pubkeys) + profiles.syncMutualContacts(pubkeys) // Emit contact list update onContactListUpdate(pubkeys) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index a326965..889e79b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) { suspend fun getUserMetadata() { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") // Get the latest metadata event val metadataFilter = @@ -73,22 +74,13 @@ class ProfileManager(private val nostr: Nostr) { } } - suspend fun getMutualContacts(pubkeys: List) { + suspend fun syncMutualContacts(pubkeys: List) { try { val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "mutual-contacts", - closeOn = opts, - ) + client?.sync(filter, relays) } catch (e: Exception) { throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) } @@ -146,7 +138,8 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") try { val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index 7c5569d..d763018 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -134,8 +134,9 @@ class RelayManager(private val nostr: Nostr) { val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val filter = Filter().kind(kind).author(publicKey).limit(1u) val events = client?.database()?.query(filter) + val event = events?.first() ?: return emptyList() - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + return nip17ExtractRelayList(event) } catch (e: Exception) { throw IllegalStateException("Failed to get msg relays: ${e.message}", e) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index bfeef8a..4872ac7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -1,5 +1,7 @@ package su.reya.coop.nostr +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull @@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { @Volatile private var signer: AsyncNostrSigner = initialSigner - @Volatile - var currentUser: PublicKey? = null - private set + private val _publicKeyFlow = MutableStateFlow(null) + val publicKeyFlow = _publicKeyFlow.asStateFlow() /** * Get the current signer. @@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { throw IllegalStateException("Failed to get public key from signer", e) } signer = newSigner - currentUser = pubkey + _publicKeyFlow.value = pubkey } override suspend fun getPublicKeyAsync(): PublicKey? { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt new file mode 100644 index 0000000..8116902 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ErrorRepository.kt @@ -0,0 +1,13 @@ +package su.reya.coop.repository + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +object ErrorRepository { + private val _errors = Channel(Channel.BUFFERED) + val errors = _errors.receiveAsFlow() + + fun showError(message: String) { + _errors.trySend(message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt new file mode 100644 index 0000000..05fe570 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/MediaRepository.kt @@ -0,0 +1,39 @@ +package su.reya.coop.repository + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import rust.nostr.sdk.AsyncNostrSigner +import su.reya.coop.blossom.BlossomClient + +class MediaRepository { + private val httpClient = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + + suspend fun blossomUpload( + signer: AsyncNostrSigner, + file: ByteArray, + contentType: String? = "image/jpeg" + ): String? { + return try { + val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = signer, + ) + descriptor?.url + } catch (e: Exception) { + println("Upload failed: ${e.message}") + null + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt similarity index 53% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 1fe5b7f..5d8b226 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -1,9 +1,9 @@ -package su.reya.coop.viewmodels +package su.reya.coop.viewmodel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.AsyncNostrSigner @@ -15,36 +15,56 @@ import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.SignerPermissions +import su.reya.coop.repository.MediaRepository import su.reya.coop.storage.SecretStorage import kotlin.time.Duration.Companion.seconds -class AccountViewModel( - val nostr: Nostr, +data class AuthState( + val isBusy: Boolean = false, + val signerRequired: Boolean? = null, + val isNotificationBannerDismissed: Boolean = false, +) + +class AuthViewModel( + private val nostr: Nostr, private val secretStore: SecretStorage, - private val appViewModel: AppViewModel, private val externalSignerHandler: ExternalSignerHandler? = null, -) : ViewModel() { - private val _signerRequired = MutableStateFlow(null) - val signerRequired = _signerRequired.asStateFlow() +) : BaseViewModel() { + private val mediaRepository = MediaRepository() + + companion object { + private const val KEY_USER_SIGNER = "user_signer" + private const val KEY_APP_KEYS = "app_keys" + private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed" + } + + private val _state = MutableStateFlow(AuthState()) + val state = _state.asStateFlow() init { - // Skip the splash screen if a user is already logged in - if (nostr.signer.currentUser != null) { - _signerRequired.value = false - } - // Check local stored secret + // Check if the notification banner has been dismissed + checkNotificationBannerDismissedStatus() + + // Check local stored secret (secret key or bunker) login() } + private fun checkNotificationBannerDismissedStatus() { + viewModelScope.launch { + val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true" + _state.update { it.copy(isNotificationBannerDismissed = dismissed) } + } + } + private fun login() { viewModelScope.launch { try { val secret = withTimeoutOrNull(3.seconds) { - secretStore.get("user_signer") + secretStore.get(KEY_USER_SIGNER) } if (secret == null) { - _signerRequired.value = true + _state.update { it.copy(signerRequired = true) } return@launch } @@ -52,44 +72,59 @@ class AccountViewModel( val signer = createSigner(secret) nostr.setSigner(signer) }.onSuccess { - _signerRequired.value = false + _state.update { it.copy(signerRequired = false) } }.onFailure { e -> - appViewModel.showError("Login failed: ${e.message}") - _signerRequired.value = true + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } } } catch (e: Exception) { - appViewModel.showError("Login failed: ${e.message}") - _signerRequired.value = true + showError("Login failed: ${e.message}") + _state.update { it.copy(signerRequired = true) } } } } - fun logout() { + fun logout(onLogout: () -> Unit = {}) { viewModelScope.launch { try { - appViewModel.setBusy(true) + _state.update { it.copy(isBusy = 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}") + 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 + secretStore.clear(KEY_USER_SIGNER) + secretStore.clear(KEY_BANNER_DISMISSED) + + // Call cleanup callback (e.g. to reset other ViewModels) + onLogout() + + _state.update { it.copy(isBusy = false, signerRequired = true) } } } } - private suspend fun getOrInitAppKeys(): Keys { - val secret = secretStore.get("app_keys") - if (secret != null) return Keys.parse(secret) + fun dismissNotificationBanner() { + viewModelScope.launch { + secretStore.set(KEY_BANNER_DISMISSED, "true") + _state.update { it.copy(isNotificationBannerDismissed = true) } + } + } + private suspend fun getOrInitAppKeys(): Keys { + val secret = secretStore.get(KEY_APP_KEYS) + + // If app keys are already stored, use them + if (secret != null) { + return Keys.parse(secret) + } + + // Generate new app keys and save to the secret storage val keys = Keys.generate() - secretStore.set("app_keys", keys.secretKey().toBech32()) + secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) return keys } @@ -97,6 +132,7 @@ class AccountViewModel( 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) @@ -107,9 +143,12 @@ class AccountViewModel( 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) } @@ -122,58 +161,34 @@ class AccountViewModel( try { val signer = createSigner(secret) if (secret.startsWith("bunker://")) { - appViewModel.showError("Please approve the connection.") + showError("Please approve the connection.") } return signer.getPublicKeyAsync() } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") + showError("Error: ${e.message}") return null } } suspend fun importIdentity(secret: String) { - appViewModel.setBusy(true) - + _state.update { it.copy(isBusy = true) } try { val signer = createSigner(secret) + // Update signer nostr.setSigner(signer) - secretStore.set("user_signer", secret) - _signerRequired.value = false + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) + // Update local states + _state.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - suspend fun createIdentity( - name: String, - bio: String?, - picture: ByteArray?, - contentType: String? = "image/jpeg", - ) { - appViewModel.setBusy(true) - - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - - try { - val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } - nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) - // Set credentials in persistent storage - secretStore.set("user_signer", secret) - // Update local state - _signerRequired.value = false - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) + showError("Error: ${e.message}") + _state.update { it.copy(isBusy = false) } } } suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - appViewModel.setBusy(true) + _state.update { it.copy(isBusy = true) } try { val permissions = SignerPermissions.toJson( listOf( @@ -192,19 +207,53 @@ class AccountViewModel( val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") val signer = ExternalSignerProxy(handler, result.pubkey) + + // Update signer nostr.setSigner(signer) - // Set credentials in persistent storage - secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}") - // Update local state - _signerRequired.value = false + // Store the signer in the secret storage + secretStore.set( + KEY_USER_SIGNER, + "nip55://${result.packageName}/${result.pubkey.toHex()}" + ) + // Update local states + _state.update { it.copy(signerRequired = false, isBusy = false) } } catch (e: Exception) { - throw Exception("Notice: ${e.message}") - } finally { - appViewModel.setBusy(false) + _state.update { it.copy(isBusy = false) } + showError("Notice: ${e.message}") } } fun isExternalSignerAvailable(): Boolean { return externalSignerHandler?.isAvailable() == true } + + suspend fun createIdentity( + name: String, + bio: String?, + picture: ByteArray?, + contentType: String? = null + ) { + _state.update { it.copy(isBusy = true) } + + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + + try { + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") + } + + // Create identity + nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) + + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) + + // Update local states + _state.update { it.copy(isBusy = false, signerRequired = false) } + } catch (e: Exception) { + showError("Error: ${e.message}") + _state.update { it.copy(isBusy = false) } + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..e1b0c13 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt @@ -0,0 +1,10 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.ViewModel +import su.reya.coop.repository.ErrorRepository + +abstract class BaseViewModel : ViewModel() { + protected fun showError(message: String) { + ErrorRepository.showError(message) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..e848f5f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -0,0 +1,260 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.Room +import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository +import su.reya.coop.roomId + +data class ChatState( + val rooms: Set = emptySet(), + val isSyncing: Boolean = false, + val isPartialProcessedGiftWrap: Boolean = false, +) + +class ChatViewModel(private val nostr: Nostr) : BaseViewModel() { + private val mediaRepository = MediaRepository() + + private val _state = MutableStateFlow(ChatState()) + val state = combine( + _state, + nostr.messages.messageSyncState + ) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ChatState() + ) + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + private val _sentReports = MutableSharedFlow>>() + val sentReport = _sentReports.asSharedFlow() + + val chatRooms = state.map { it.rooms } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val isSyncing = state.map { it.isSyncing } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + init { + viewModelScope.launch { + nostr.waitUntilInitialized() + + // Observe message sync progress + launch { + nostr.messages.messageSyncState.collect { syncState -> + // When at least some messages are processed, allow UI to show the list + if (syncState.processedCount > 0) { + _state.update { it.copy(isPartialProcessedGiftWrap = true) } + } + + // Refresh UI every 10 messages OR when sync is fully done + if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) { + refreshChatRooms() + } + } + } + + // Observe new messages + launch { + nostr.newEvents.collect { event -> + val roomId = event.roomId() + val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId } + + if (existingRoom == null) { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect + val newRoom = Room.new(event, currentUser) + _state.update { + it.copy( + rooms = (it.rooms + newRoom).sortedDescending().toSet() + ) + } + } else { + updateRoomList(roomId, event) + } + + _newEvents.emit(event) + } + } + + // Initial load of rooms + refreshChatRooms() + } + } + + fun createChatRoom(to: List): Long { + try { + if (to.isEmpty()) { + throw IllegalArgumentException("At least one recipient is required") + } + + // Get current user + val currentUser = nostr.signer.publicKeyFlow.value + ?: throw IllegalStateException("User not signed in") + + // Construct the rumor event + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") + .tags(to.map { Tag.publicKey(it) }) + .finalizeUnsigned(currentUser) + + // Check if the room already exists + val id = rumor.roomId() + val existingRoom = _state.value.rooms.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 + _state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) } + + return room.id + } catch (e: Exception) { + throw IllegalArgumentException("Failed to create room: ${e.message}") + } + } + + fun getChatRoom(id: Long): Room? { + return _state.value.rooms.firstOrNull { it.id == id } + } + + suspend fun refreshChatRooms() { + try { + val rooms = nostr.messages.getChatRooms() ?: emptySet() + _state.update { currentState -> + val merged = currentState.rooms.associateBy { it.id }.toMutableMap() + // Add or update rooms from the database + rooms.forEach { room -> + merged[room.id] = room + } + // Return as a sorted set to maintain UI consistency + currentState.copy(rooms = merged.values.sortedDescending().toSet()) + } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + return nostr.messages.getChatRoomMessages(roomId) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + + return emptyList() + } + + fun chatRoomConnect(roomId: Long) { + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + val members = room.members + + nostr.messages.chatRoomConnect(members.toList()) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + if (message.isEmpty()) { + showError("Message cannot be empty") + } + viewModelScope.launch { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.sendMessage( + to = room.members, + content = message, + subject = room.subject, + replies = replies, + onRumorCreated = { event -> + updateRoomList(roomId, event) + viewModelScope.launch { _newEvents.emit(event) } + }, + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + + suspend fun sendFileMessage( + roomId: Long, + file: ByteArray?, + contentType: String? = "image/jpeg", + replies: List = emptyList() + ) { + if (file == null) return + + try { + val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType) + if (uri != null) sendMessage(roomId, uri, replies) + } catch (e: Exception) { + throw IllegalArgumentException("Error: ${e.message}") + } + } + + fun isMessageSent(id: EventId): Boolean { + val giftWrapId = nostr.messages.rumorMap[id] + + if (giftWrapId != null) { + val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false + return isSent + } else { + return false + } + } + + private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { + _state.update { currentState -> + val updatedRooms = currentState.rooms.map { room -> + if (room.id == roomId) { + room.copy( + lastMessage = newMessage.content(), + createdAt = newMessage.createdAt() + ) + } else { + room + } + }.sortedDescending().toSet() + currentState.copy(rooms = updatedRooms) + } + } + + fun resetInternalState() { + _state.update { + it.copy( + rooms = emptySet(), + isPartialProcessedGiftWrap = false, + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt new file mode 100644 index 0000000..5fb8c89 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -0,0 +1,446 @@ +package su.reya.coop.viewmodel + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.Timestamp +import su.reya.coop.Profile +import su.reya.coop.nostr.Nostr +import su.reya.coop.repository.MediaRepository +import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +data class NostrAppState( + val isBusy: Boolean = false, + val isRelayListEmpty: Boolean = false, +) + +class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { + private val mediaRepository = MediaRepository() + + private val alwaysRunTasks = flow { + coroutineScope { + val observerJob = launch { runObserver() } + val batchingJob = launch { runMetadataBatching() } + try { + emit(Unit) + awaitCancellation() + } finally { + observerJob.cancel() + batchingJob.cancel() + } + } + } + + private val _appState = MutableStateFlow(NostrAppState()) + val appState: StateFlow = + combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = NostrAppState() + ) + + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + + private val profilesMutex = Mutex() + private val profiles = mutableMapOf>() + private val metadataRequestChannel = Channel(Channel.UNLIMITED) + private val seenPublicKeys = mutableSetOf() + + val isBusy = appState.map { it.isBusy } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val isRelayListEmpty = appState.map { it.isRelayListEmpty } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) getMetadata(pubkey) else flowOf(null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + init { + // Automatically reconnect bootstrap relays + reconnect() + + // Observe the signer state and verify the relay list + observeSignerAndCheckRelays() + + // Get all local stored metadata + getCacheMetadata() + } + + private fun reconnect() { + viewModelScope.launch { + nostr.waitUntilInitialized() + nostr.reconnect() + } + } + + private suspend fun runObserver() = coroutineScope { + // 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, Profile(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() + + nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) -> + // Update the metadata state + updateMetadata(pubkey, Profile(pubkey, metadata)) + // Update seenPublicKeys to avoid duplicate requests + seenPublicKeys.add(pubkey) + } + } + } + + private fun observeSignerAndCheckRelays() { + viewModelScope.launch { + // Wait until the client is ready + nostr.waitUntilInitialized() + + // Wait until a signer is explicitly set (which updates publicKeyFlow) + val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first() + + // 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(currentUser) + if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) } + } + } + + private fun requestMetadata(pubkey: PublicKey) { + if (seenPublicKeys.add(pubkey)) { + viewModelScope.launch { + metadataRequestChannel.send(pubkey) + } + } + } + + private fun updateMetadata(pubkey: PublicKey, profile: Profile) { + viewModelScope.launch { + profilesMutex.withLock { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile + } + } + } + + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) requestMetadata(pubkey) + + return flow.asStateFlow() + } + + fun resetInternalState() { + _contactList.value = emptySet() + _appState.update { + it.copy( + isRelayListEmpty = false, + ) + } + } + + fun dismissRelayWarning() { + _appState.update { it.copy(isRelayListEmpty = false) } + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: ByteArray? = null, + contentType: String? = null + ) { + _appState.update { it.copy(isBusy = true) } + try { + val avatarUrl = + picture?.let { + mediaRepository.blossomUpload( + nostr.signer.get(), + it, + contentType ?: "image/jpeg" + ) + } + val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) + val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + + // Update the metadata state after successfully published + updateMetadata(currentUser, Profile(currentUser, newMetadata)) + + // Update local state + _appState.update { it.copy(isBusy = false) } + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + + suspend fun refetchMsgRelays() { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return + val relays = nostr.relays.fetchMsgRelays(currentUser) + + 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}") + } + } + } + + suspend fun searchByAddress(query: String): PublicKey? { + try { + return nostr.profiles.searchByAddress(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return null + } + + suspend fun searchByNostr(query: String): List { + try { + return nostr.profiles.searchByNostr(query) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + return emptyList() + } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + return try { + nostr.profiles.verifyActivity(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + null + } + } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + return try { + nostr.profiles.verifyContact(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + false + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + return try { + nostr.profiles.mutualContacts(pubkey) + } catch (e: Exception) { + showError("Error: ${e.message}") + setOf() + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt deleted file mode 100644 index e2eff28..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt +++ /dev/null @@ -1,92 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import su.reya.coop.blossom.BlossomClient -import su.reya.coop.nostr.Nostr -import su.reya.coop.storage.SecretStorage - -class AppViewModel( - private val nostr: Nostr, - private val secretStore: SecretStorage -) : ViewModel() { - private val _isBusy = MutableStateFlow(false) - val isBusy = _isBusy.asStateFlow() - - private val _isNotificationBannerDismissed = MutableStateFlow(false) - val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() - - private val _errorEvents = Channel(Channel.BUFFERED) - val errorEvents = _errorEvents.receiveAsFlow() - - init { - checkNotificationBannerDismissedStatus() - } - - fun setBusy(busy: Boolean) { - _isBusy.value = busy - } - - fun showError(message: String) { - viewModelScope.launch { - _errorEvents.send(message) - } - } - - private fun checkNotificationBannerDismissedStatus() { - viewModelScope.launch { - _isNotificationBannerDismissed.value = - secretStore.get("notification_banner_dismissed") == "true" - } - } - - fun dismissNotificationBanner() { - viewModelScope.launch { - secretStore.set("notification_banner_dismissed", "true") - _isNotificationBannerDismissed.value = true - } - } - - suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? { - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - ) - - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = nostr.signer.get() - ) - - return descriptor?.url - } - - override fun onCleared() { - super.onCleared() - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt deleted file mode 100644 index 897044d..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt +++ /dev/null @@ -1,221 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Kind -import rust.nostr.sdk.KindStandard -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Tag -import rust.nostr.sdk.UnsignedEvent -import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.roomId - -class ChatViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel -) : ViewModel() { - private val _chatRooms = MutableStateFlow>(emptySet()) - val chatRooms = _chatRooms.asStateFlow() - - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _isPartialProcessedGiftWrap = MutableStateFlow(false) - val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow() - - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - - suspend fun bindObservers() = coroutineScope { - launch { - nostr.messages.messageSyncState.collect { state -> - if (state.processedCount > 0) { - _isPartialProcessedGiftWrap.value = true - } - if (state.processedCount % 10 == 0 || !state.isSyncing) { - refreshChatRooms() - } - } - } - - launch { - nostr.newEvents.collect { event -> - val roomId = event.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } - - if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } - } - } else { - updateRoomList(roomId, event) - } - - _newEvents.emit(event) - } - } - } - - fun createChatRoom(to: List): Long { - try { - val currentUser = - nostr.signer.currentUser ?: throw IllegalStateException("User not signed in") - if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") - - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") - .tags(to.map { Tag.publicKey(it) }) - .finalizeUnsigned(currentUser) - - val id = rumor.roomId() - val existingRoom = _chatRooms.value.firstOrNull { it.id == id } - - if (existingRoom != null) { - return existingRoom.id - } - - val room = Room.new(rumor, currentUser) - _chatRooms.update { currentRooms -> - (currentRooms + room).sortedDescending().toSet() - } - - return room.id - } catch (e: Exception) { - throw IllegalArgumentException("Failed to create room: ${e.message}") - } - } - - fun getChatRoom(id: Long): Room? { - return chatRooms.value.firstOrNull { it.id == id } - } - - fun getChatRooms() { - viewModelScope.launch { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } - } - - suspend fun refreshChatRooms() { - try { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - mergeChatRooms(rooms) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - fun mergeChatRooms(rooms: Set) { - _chatRooms.update { currentRooms -> - val merged = currentRooms.associateBy { it.id }.toMutableMap() - rooms.forEach { room -> - merged[room.id] = room - } - merged.values.sortedDescending().toSet() - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - return nostr.messages.getChatRoomMessages(roomId) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return emptyList() - } - - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.chatRoomConnect(room.members.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - fun sendMessage( - roomId: Long, - message: String, - replies: List = emptyList() - ) { - if (message.isEmpty()) { - appViewModel.showError("Message cannot be empty") - return - } - viewModelScope.launch { - try { - val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.messages.sendMessage( - to = room.members, - content = message, - subject = room.subject, - replies = replies, - onRumorCreated = { event -> - updateRoomList(roomId, event) - viewModelScope.launch { _newEvents.emit(event) } - }, - ) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - suspend fun sendFileMessage( - roomId: Long, - file: ByteArray?, - contentType: String? = "image/jpeg", - replies: List = emptyList() - ) { - if (file == null) return - - try { - val uri = appViewModel.blossomUpload(file, contentType) - ?: throw IllegalArgumentException("Failed to upload file") - - sendMessage(roomId, uri, replies) - } catch (e: Exception) { - throw IllegalArgumentException("Error: ${e.message}") - } - } - - fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.messages.rumorMap[id] - return if (giftWrapId != null) { - nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false - } else { - false - } - } - - private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) { - _chatRooms.update { currentRooms -> - currentRooms.map { room -> - if (room.id == roomId) { - room.copy( - lastMessage = newMessage.content(), - createdAt = newMessage.createdAt() - ) - } else { - room - } - }.sortedDescending().toSet() - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt deleted file mode 100644 index 3553c50..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt +++ /dev/null @@ -1,216 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import rust.nostr.sdk.Metadata -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Timestamp -import su.reya.coop.nostr.Nostr -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds - -class ProfileViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel -) : ViewModel() { - private val _contactList = MutableStateFlow>(emptySet()) - val contactList = _contactList.asStateFlow() - - private val _metadataStore = mutableMapOf>() - private val metadataRequestChannel = Channel(Channel.UNLIMITED) - private val seenPublicKeys = mutableSetOf() - - init { - getCacheMetadata() - } - - suspend fun bindObservers() = coroutineScope { - launch { - nostr.profiles.contactListUpdates.collect { contacts -> - _contactList.value = contacts.toSet() - } - } - - launch { - nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - } - } - - launch { runMetadataBatching() } - } - - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata - } - - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) { - requestMetadata(pubkey) - } - return flow.asStateFlow() - } - - private fun requestMetadata(pubkey: PublicKey) { - if (seenPublicKeys.add(pubkey)) { - viewModelScope.launch { - metadataRequestChannel.send(pubkey) - } - } - } - - private fun getCacheMetadata() { - viewModelScope.launch { - nostr.waitUntilInitialized() - val results = nostr.profiles.getAllCacheMetadata() - results.forEach { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) - seenPublicKeys.add(pubkey) - } - } - } - - private suspend fun runMetadataBatching() = coroutineScope { - nostr.waitUntilInitialized() - val batch = mutableSetOf() - val timeout = 500L - - while (true) { - val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) - val lastFlushTime = Clock.System.now().toEpochMilliseconds() - - while (batch.isNotEmpty()) { - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - metadataRequestChannel.receive() - } - if (nextKey != null) batch.add(nextKey) - val now = Clock.System.now().toEpochMilliseconds() - - if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { - val keysToRequest = batch.toList() - batch.clear() - nostr.profiles.fetchMetadataBatch(keysToRequest) - } - } - } - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: ByteArray? = null, - contentType: String? = "image/jpeg" - ) { - appViewModel.setBusy(true) - try { - val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } - val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - - updateMetadata(currentUser, newMetadata) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } finally { - appViewModel.setBusy(false) - } - } - - suspend fun addContact(address: String): Boolean { - val pubkey = try { - if (address.contains("@")) { - nostr.profiles.searchByAddress(address) - } else { - PublicKey.parse(address) - } - } catch (e: Exception) { - appViewModel.showError("Invalid contact address: ${e.message}") - return false - } - - if (pubkey in contactList.value) return true - - return try { - val updated = contactList.value + pubkey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it + pubkey } - true - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - false - } - } - - fun removeContact(publicKey: PublicKey) { - viewModelScope.launch { - if (publicKey !in contactList.value) return@launch - try { - val updated = contactList.value - publicKey - nostr.profiles.setContactList(updated.toList()) - _contactList.update { it - publicKey } - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - } - - suspend fun searchByAddress(query: String): PublicKey? { - try { - return nostr.profiles.searchByAddress(query) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return null - } - - suspend fun searchByNostr(query: String): List { - try { - return nostr.profiles.searchByNostr(query) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - return emptyList() - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - return try { - nostr.profiles.verifyActivity(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - null - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - return try { - nostr.profiles.verifyContact(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - false - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - return try { - nostr.profiles.mutualContacts(pubkey) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - setOf() - } - } - - fun getUserMetadata() { - viewModelScope.launch { - nostr.profiles.getUserMetadata() - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt deleted file mode 100644 index b779ef9..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/RelayViewModel.kt +++ /dev/null @@ -1,149 +0,0 @@ -package su.reya.coop.viewmodels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayUrl -import su.reya.coop.nostr.Nostr -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class RelayViewModel( - private val nostr: Nostr, - private val appViewModel: AppViewModel, - private val chatViewModel: ChatViewModel, - private val profileViewModel: ProfileViewModel -) : ViewModel() { - private val _isRelayListEmpty = MutableStateFlow(false) - val isRelayListEmpty = _isRelayListEmpty.asStateFlow() - - fun reconnect() { - viewModelScope.launch { - nostr.waitUntilInitialized() - nostr.reconnect() - } - } - - fun observeSignerAndCheckRelays() { - viewModelScope.launch { - while (true) { - val pubkey = nostr.signer.currentUser - if (pubkey != null) { - val rooms = nostr.messages.getChatRooms() ?: emptySet() - if (rooms.isNotEmpty()) { - chatViewModel.mergeChatRooms(rooms) - // Note: isPartialProcessedGiftWrap is in ChatViewModel - // We might need to expose a way to set it or just let it be updated by observers - } - - profileViewModel.getUserMetadata() - - delay(2.seconds) - - val relays = nostr.relays.getMsgRelays(pubkey) - if (relays.isEmpty()) _isRelayListEmpty.value = true - break - } - delay(500.milliseconds) - } - } - } - - suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.relays.fetchMsgRelays(pubkey) - if (relays.isNotEmpty()) dismissRelayWarning() - } - - fun dismissRelayWarning() { - _isRelayListEmpty.value = false - } - - suspend fun useDefaultMsgRelayList() { - try { - val defaultRelays = nostr.relays.getDefaultMsgRelayList() - nostr.relays.setMsgRelays(defaultRelays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun currentUserRelayList(): Map { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getRelayList(currentUser) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return emptyMap() - } - } - - suspend fun addInboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.WRITE - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun addOutboxRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays[relayUrl] = RelayMetadata.READ - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun removeRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserRelayList().toMutableMap() - relays.remove(relayUrl) - nostr.relays.setRelaylist(relays) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun currentUserMsgRelayList(): List { - try { - val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.relays.getMsgRelays(currentUser) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return emptyList() - } - } - - suspend fun addMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.add(relayUrl) - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } - - suspend fun removeMsgRelay(relay: String) { - try { - val relayUrl = RelayUrl.parse(relay) - val relays = currentUserMsgRelayList().toMutableSet() - relays.remove(relayUrl) - nostr.relays.setMsgRelays(relays.toList()) - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - } - } -}