From 320a986212231d7a745f7de4792fd627ef15f621 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 2 Jul 2026 10:41:10 +0700 Subject: [PATCH] refactor --- .../su/reya/coop/NostrForegroundService.kt | 1 - .../kotlin/su/reya/coop/screens/ChatScreen.kt | 67 ++++++---------- .../su/reya/coop/screens/ContactListScreen.kt | 14 ++-- .../kotlin/su/reya/coop/screens/HomeScreen.kt | 68 ++++++---------- .../su/reya/coop/screens/ImportScreen.kt | 12 +-- .../kotlin/su/reya/coop/screens/MyQrScreen.kt | 8 +- .../su/reya/coop/screens/NewChatScreen.kt | 28 +++---- .../su/reya/coop/screens/ProfileScreen.kt | 22 +++--- .../su/reya/coop/screens/RequestListScreen.kt | 2 +- .../reya/coop/screens/UpdateProfileScreen.kt | 6 +- .../kotlin/su/reya/coop/shared/RoomHelper.kt | 45 ----------- shared/build.gradle.kts | 2 + .../kotlin/su/reya/coop/NostrViewModel.kt | 78 ++++++++++--------- .../commonMain/kotlin/su/reya/coop/Profile.kt | 20 +++++ .../kotlin/su/reya/coop/{nostr => }/Room.kt | 64 ++++++++++++++- .../kotlin/su/reya/coop/nostr/Messaging.kt | 7 +- .../kotlin/su/reya/coop/nostr/Nostr.kt | 2 +- .../su/reya/coop/nostr/ProfileManager.kt | 6 +- .../kotlin/su/reya/coop/nostr/Signer.kt | 9 ++- 19 files changed, 223 insertions(+), 238 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/Profile.kt rename shared/src/commonMain/kotlin/su/reya/coop/{nostr => }/Room.kt (74%) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index a604a0e..76ebe5b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import su.reya.coop.nostr.NostrManager -import su.reya.coop.nostr.roomId import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 0ba8360..1a02a1c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -70,7 +70,6 @@ import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource @@ -80,15 +79,14 @@ import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.formatAsGroupHeader -import su.reya.coop.nostr.humanReadable -import su.reya.coop.nostr.roomId +import su.reya.coop.formatAsGroupHeader +import su.reya.coop.humanReadable +import su.reya.coop.rememberUiState +import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -101,6 +99,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) { val scope = rememberCoroutineScope() val listState = rememberLazyListState() + // Get current user + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + // Get chat room by ID val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } @@ -120,15 +121,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { return } - - val displayName by remember(room) { - room?.nameFlow(nostrViewModel) ?: flowOf("Loading...") - }.collectAsStateWithLifecycle("Loading...") - - val picture by remember(room) { - room?.pictureFlow(nostrViewModel) ?: flowOf(null) - }.collectAsStateWithLifecycle(null) - + val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) var text by remember { mutableStateOf("") } var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } @@ -212,14 +205,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) { LoadingIndicator(modifier = Modifier.size(32.dp)) } else { Avatar( - picture = picture, - description = displayName, + picture = roomState.picture, + description = roomState.name, size = 32.dp, ) } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, ) } @@ -279,7 +272,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) { items = messagesInGroup, key = { it.id()?.toBech32() ?: it.hashCode() } ) { - ChatMessage(it) + ChatMessage( + rumor = it, + isMine = currentUser?.publicKey == it.author() + ) } item { DateSeparator(dateHeader) @@ -381,12 +377,8 @@ fun ScreenerCard(room: Room) { var mutualContacts by remember { mutableStateOf>(emptySet()) } var lastActivity by remember { mutableStateOf(null) } - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsStateWithLifecycle() - - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsStateWithLifecycle() LaunchedEffect(pubkey) { scope.launch { @@ -412,7 +404,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.size(120.dp), shape = MaterialShapes.Cookie12Sided.toShape(), @@ -422,7 +414,7 @@ fun ScreenerCard(room: Room) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -511,12 +503,9 @@ fun DateSeparator(date: String) { @Composable fun ChatMessage( - rumor: UnsignedEvent + rumor: UnsignedEvent, + isMine: Boolean = false, ) { - val nostrViewModel = LocalNostrViewModel.current - val currentUser = nostrViewModel.nostr.signer.currentUser - val isMine = rumor.author() == currentUser - val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) } else { @@ -542,17 +531,7 @@ fun ChatMessage( color = containerColor, contentColor = contentColor, shape = bubbleShape, - modifier = Modifier - .widthIn(max = 280.dp) - .clickable( - onClick = { - val id = rumor.id() - if (id != null) { - val sent = nostrViewModel.isMessageSent(id) - println("Sent: $sent") - } - } - ) + modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = rumor.content(), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt index 9c8f607..e8c6cbf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ContactListScreen.kt @@ -284,12 +284,8 @@ fun ContactListItem( onClick: () -> Unit, ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( onClick = onClick, @@ -300,15 +296,15 @@ fun ContactListItem( ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name, size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 3431459..ab8e59f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -87,7 +87,6 @@ import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey @@ -95,15 +94,13 @@ import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Room +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.Room -import su.reya.coop.nostr.RoomKind -import su.reya.coop.nostr.ago +import su.reya.coop.ago +import su.reya.coop.rememberUiState import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.shared.nameFlow -import su.reya.coop.shared.pictureFlow -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -120,11 +117,9 @@ fun HomeScreen() { val listState = rememberLazyListState() val pullToRefreshState = rememberPullToRefreshState() - val currentUser = nostrViewModel.nostr.signer.currentUser ?: return - val currentUserProfile = nostrViewModel.getMetadata(currentUser) - - val userProfile by currentUserProfile.collectAsStateWithLifecycle() + val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() + val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() @@ -209,8 +204,8 @@ fun HomeScreen() { // User IconButton(onClick = { showBottomSheet = true }) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", size = 32.dp, ) } @@ -391,14 +386,6 @@ fun HomeScreen() { onDismissRequest = { showBottomSheet = false }, sheetState = sheetState, ) { - val pubkey = nostrViewModel.nostr.signer.currentUser - val shortPubkey = pubkey?.short() ?: "Not available" - - val userName = - userProfile?.asRecord()?.displayName - ?: userProfile?.asRecord()?.name - ?: "No name" - val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> scope.launch { sheetState.hide() @@ -423,8 +410,8 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = userProfile?.asRecord()?.picture, - description = userProfile?.asRecord()?.displayName, + picture = userProfile?.picture, + description = userProfile?.name ?: "No name", shape = MaterialShapes.Cookie9Sided.toShape(), modifier = Modifier.fillMaxSize() ) @@ -434,7 +421,7 @@ fun HomeScreen() { contentAlignment = Alignment.Center ) { Text( - text = userName, + text = userProfile?.name ?: "No name", style = MaterialTheme.typography.titleLargeEmphasized, ) } @@ -445,16 +432,15 @@ fun HomeScreen() { OutlinedButton( onClick = { scope.launch { - pubkey?.let { + userProfile?.publicKey?.let { val bech32 = it.toBech32() - val data = - ClipData.newPlainText(bech32, bech32) + val data = ClipData.newPlainText(bech32, bech32) clipboardManager.setClipEntry(ClipEntry(data)) } } }, ) { - Text(text = shortPubkey) + Text(text = userProfile?.shortPublicKey ?: "Unknown") } FilledIconButton( onClick = { @@ -585,7 +571,7 @@ fun HomeScreen() { scope.launch { isBusy = true try { - nostrViewModel.refetchMsgRelays(currentUser) + nostrViewModel.refetchMsgRelays() } catch (e: Exception) { snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") } @@ -634,28 +620,23 @@ fun NewRequests(requests: List) { val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstName by remember(firstRoom) { - firstRoom?.nameFlow(nostrViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("Loading...") - - val secondName by remember(secondRoom) { - secondRoom?.nameFlow(nostrViewModel) ?: flowOf("") - }.collectAsStateWithLifecycle("") + val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) + val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) val supportingText = when { - total == 1 && firstRoom != null -> { + total == 1 -> { val message = firstRoom.lastMessage ?: "" - "$firstName: $message" + "${firstRoomState.name}: $message" } total == 2 -> { - "$firstName and $secondName" + "${firstRoomState.name} and ${secondRoomState.name}" } total > 2 -> { val othersCount = total - 2 val othersText = if (othersCount == 1) "1 other" else "$othersCount others" - "$firstName, $secondName and $othersText" + "${firstRoomState.name}, ${secondRoomState.name} and $othersText" } else -> "" @@ -712,13 +693,12 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val nostrViewModel = LocalNostrViewModel.current - val displayName by remember(room) { room.nameFlow(nostrViewModel) }.collectAsStateWithLifecycle("Loading...") - val picture by remember(room) { room.pictureFlow(nostrViewModel) }.collectAsStateWithLifecycle(null) + val roomState by room.rememberUiState(nostrViewModel) ListItem( modifier = Modifier.clickable(onClick = onClick), leadingContent = { - Avatar(picture = picture, description = displayName) + Avatar(picture = roomState.picture, description = roomState.picture) }, headlineContent = { Row( @@ -726,7 +706,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = displayName, + text = roomState.name, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f) ) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index b16cfb3..707d705 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -65,7 +65,6 @@ import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -82,15 +81,10 @@ fun ImportScreen() { var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } - // Get metadata when pubkey changes - val metadata by remember(pubkey) { + val profile by remember(pubkey) { pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) }.collectAsStateWithLifecycle(null) - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" - val picture = profile?.picture - LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { @@ -164,7 +158,7 @@ fun ImportScreen() { contentAlignment = Alignment.Center ) { Avatar( - picture = picture, + picture = profile?.picture, description = "Profile picture", modifier = Modifier.fillMaxSize(), shape = MaterialShapes.Cookie9Sided.toShape(), @@ -172,7 +166,7 @@ fun ImportScreen() { } Spacer(modifier = Modifier.size(8.dp)) Text( - text = displayName, + text = profile?.name ?: "", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt index 7b0db2a..505e806 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/MyQrScreen.kt @@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import io.github.alexzhirkevich.qrose.rememberQrCodePainter @@ -27,8 +29,8 @@ import su.reya.coop.LocalSnackbarHostState fun MyQrScreen() { val navigator = LocalNavigator.current val snackbarHostState = LocalSnackbarHostState.current - val accountViewModel = LocalNostrViewModel.current - val currentUser = accountViewModel.nostr.signer.currentUser ?: return + val nostrViewModel = LocalNostrViewModel.current + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -59,7 +61,7 @@ fun MyQrScreen() { horizontalAlignment = Alignment.CenterHorizontally, ) { Image( - painter = rememberQrCodePainter(currentUser.toBech32()), + painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""), contentDescription = "My QR" ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt index 3ee77d2..095a876 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -289,12 +289,8 @@ fun ReceiverChip( onRemove: () -> Unit ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { InputChip( @@ -302,7 +298,7 @@ fun ReceiverChip( onClick = onRemove, label = { Text( - text = displayName, + text = profile?.name ?: "No name", style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.SemiBold ) @@ -310,8 +306,8 @@ fun ReceiverChip( }, avatar = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "No name", size = 24.dp ) }, @@ -375,12 +371,8 @@ fun ContactListItem( onLongClick: () -> Unit ) { val nostrViewModel = LocalNostrViewModel.current - val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val metadata by metadataFlow.collectAsState(initial = null) - - val profile = metadata?.asRecord() - val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() - val picture = profile?.picture + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsState(initial = null) SegmentedListItem( selected = isSelected, @@ -392,15 +384,15 @@ fun ContactListItem( ), leadingContent = { Avatar( - picture = picture, - description = displayName, + picture = profile?.picture, + description = profile?.name ?: "", size = 36.dp ) }, supportingContent = { Text(text = pubkey.short()) }, content = { Text( - text = displayName, + text = profile?.name ?: "", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt index 0caf75f..3fefa14 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ProfileScreen.kt @@ -63,19 +63,18 @@ fun ProfileScreen(pubkey: String) { val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return - val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } - val metadata by metadataFlow.collectAsStateWithLifecycle() + val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } + val profile by profileFlow.collectAsStateWithLifecycle() - val profile = metadata?.asRecord() - val displayName = profile?.displayName ?: profile?.name ?: "No name" - val nip05 = profile?.nip05 ?: pubkeyObj.short() - val picture = profile?.picture + val metadata = profile?.metadata?.asRecord() + val nip05 = metadata?.nip05 ?: pubkeyObj.short() + val picture = metadata?.picture val details = remember(profile) { listOf( - "Username:" to (profile?.name ?: "None"), - "Website:" to (profile?.website ?: "None"), - "₿ Lightning Address:" to (profile?.lud16 ?: "None"), + "Username:" to (metadata?.name ?: "None"), + "Website:" to (metadata?.website ?: "None"), + "₿ Lightning Address:" to (metadata?.lud16 ?: "None"), ) } @@ -133,7 +132,7 @@ fun ProfileScreen(pubkey: String) { verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = displayName, + text = profile?.name ?: "No name", textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLargeEmphasized.copy( fontFamily = getExpressiveFontFamily() @@ -159,7 +158,8 @@ fun ProfileScreen(pubkey: String) { onClick = { scope.launch { try { - val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj)) + val roomId = + nostrViewModel.createChatRoom(listOf(pubkeyObj)) navigator.navigate(Screen.Chat(roomId)) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt index 5cb825a..c686c90 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -40,8 +40,8 @@ import kotlinx.coroutines.launch import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.RoomKind import su.reya.coop.Screen -import su.reya.coop.nostr.RoomKind @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index ed8d900..074a9b7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -15,11 +15,9 @@ fun UpdateProfileScreen() { val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val currentUser = nostrViewModel.nostr.signer.currentUser ?: return - val metadata by nostrViewModel.getMetadata(currentUser).collectAsStateWithLifecycle() val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) - - val profile = metadata?.asRecord() + val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() + val profile = currentUser?.metadata?.asRecord() ProfileEditor( title = "Update profile", diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt deleted file mode 100644 index 65fec77..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt +++ /dev/null @@ -1,45 +0,0 @@ -package su.reya.coop.shared - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import rust.nostr.sdk.PublicKey -import su.reya.coop.nostr.Room -import su.reya.coop.short -import su.reya.coop.NostrViewModel - -fun Room.nameFlow( - nostrViewModel: NostrViewModel, - currentUser: PublicKey? = null -): Flow { - // Return early if there's a custom subject/room name - subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } - - val displayMembers = if (isGroup()) members.take(2) else members.take(1) - if (displayMembers.isEmpty()) return flowOf("Unknown") - - return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { metadataArray -> - val names = metadataArray.mapIndexed { i, metadata -> - val profile = metadata?.asRecord() - profile?.displayName?.takeIf { it.isNotBlank() } - ?: profile?.name?.takeIf { it.isNotBlank() } - ?: displayMembers[i].short() - } - - if (isGroup()) { - val combined = names.joinToString(", ") - val extraCount = members.size - names.size - if (extraCount > 0) "$combined, +$extraCount" else combined - } else { - val name = names.first() - if (displayMembers.first() == currentUser) "$name (you)" else name - } - } -} - -fun Room.pictureFlow(nostrViewModel: NostrViewModel): Flow { - val firstMember = members.firstOrNull() ?: return flowOf(null) - return nostrViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } -} - diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f311307..276ec31 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) kotlin("plugin.serialization") version libs.versions.kotlin.get() } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index e77269a..b6e4d7e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -31,7 +34,6 @@ import rust.nostr.sdk.EventId import rust.nostr.sdk.Keys import rust.nostr.sdk.Kind import rust.nostr.sdk.KindStandard -import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey @@ -44,16 +46,14 @@ import su.reya.coop.blossom.BlossomClient import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.Nostr -import su.reya.coop.nostr.Room import su.reya.coop.nostr.SignerPermissions -import su.reya.coop.nostr.roomId import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class NostrViewModel( - val nostr: Nostr, + private val nostr: Nostr, private val secretStore: SecretStorage, private val externalSignerHandler: ExternalSignerHandler? = null, ) : ViewModel() { @@ -87,21 +87,25 @@ class NostrViewModel( private val _errorEvents = Channel(Channel.BUFFERED) val errorEvents = _errorEvents.receiveAsFlow() - private val _metadataStore = mutableMapOf>() + private val profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState + .map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - init { - // Skip the splash screen if a user is already logged in - if (nostr.signer.currentUser != null) { - _signerRequired.value = false - } + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) getMetadata(pubkey) else flowOf(null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + init { // Check if the notification banner has been dismissed checkNotificationBannerDismissedStatus() @@ -183,11 +187,9 @@ class NostrViewModel( val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } if (existingRoom == null) { - val currentUser = nostr.signer.currentUser - if (currentUser != null) { - val newRoom = Room.new(event, currentUser) - _chatRooms.update { (it + newRoom).sortedDescending().toSet() } - } + val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect + val newRoom = Room.new(event, currentUser) + _chatRooms.update { (it + newRoom).sortedDescending().toSet() } } else { updateRoomList(roomId, event) } @@ -206,7 +208,7 @@ class NostrViewModel( // Observe metadata updates launch { nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> - updateMetadata(pubkey, metadata) + updateMetadata(pubkey, Profile(pubkey, metadata)) } } } @@ -257,7 +259,7 @@ class NostrViewModel( val results = nostr.profiles.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> // Update the metadata state - updateMetadata(pubkey, metadata) + updateMetadata(pubkey, Profile(pubkey, metadata)) // Update seenPublicKeys to avoid duplicate requests seenPublicKeys.add(pubkey) } @@ -295,9 +297,9 @@ class NostrViewModel( private fun observeSignerAndCheckRelays() { viewModelScope.launch { while (true) { - val pubkey = nostr.signer.currentUser + val currentUser = nostr.signer.getPublicKeyAsync() - if (pubkey != null) { + if (currentUser != null) { // Get chat rooms val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { @@ -312,7 +314,7 @@ class NostrViewModel( delay(2.seconds) // Check if the relay list is empty - val relays = nostr.relays.getMsgRelays(pubkey) + val relays = nostr.relays.getMsgRelays(currentUser) if (relays.isEmpty()) _isRelayListEmpty.value = true break @@ -331,22 +333,17 @@ class NostrViewModel( } } - private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { - _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata + private fun updateMetadata(pubkey: PublicKey, profile: Profile) { + profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile } - fun getMetadata(pubkey: PublicKey): StateFlow { - val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } - if (flow.value == null) { - requestMetadata(pubkey) - } + fun getMetadata(pubkey: PublicKey): StateFlow { + val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) } + if (flow.value == null) requestMetadata(pubkey) + return flow.asStateFlow() } - fun currentUser(): PublicKey? { - return nostr.signer.currentUser - } - fun logout() { viewModelScope.launch { try { @@ -445,7 +442,7 @@ class NostrViewModel( val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") // Update the metadata state after successfully published - updateMetadata(currentUser, newMetadata) + updateMetadata(currentUser, Profile(currentUser, newMetadata)) // Update local state _isBusy.value = false } catch (e: Exception) { @@ -573,8 +570,10 @@ class NostrViewModel( return externalSignerHandler?.isAvailable() == true } - suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.relays.fetchMsgRelays(pubkey) + suspend fun refetchMsgRelays() { + val currentUser = nostr.signer.getPublicKeyAsync() ?: return + val relays = nostr.relays.fetchMsgRelays(currentUser) + if (relays.isNotEmpty()) dismissRelayWarning() } @@ -717,10 +716,13 @@ class NostrViewModel( fun createChatRoom(to: List): Long { try { - if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") - if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + if (to.isEmpty()) { + throw IllegalArgumentException("At least one recipient is required") + } - val currentUser = nostr.signer.currentUser ?: throw Exception("User not found") + // Get current user + val currentUser = nostr.signer.publicKeyFlow.value + ?: throw IllegalStateException("User not signed in") // Construct the rumor event val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt new file mode 100644 index 0000000..8b40cd1 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/Profile.kt @@ -0,0 +1,20 @@ +package su.reya.coop + +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.PublicKey + +data class Profile( + val publicKey: PublicKey, + val metadata: Metadata +) { + private val record by lazy { metadata.asRecord() } + + val name: String + get() = record.displayName ?: record.name ?: publicKey.short() + + val picture: String? + get() = record.picture + + val shortPublicKey: String + get() = publicKey.short() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt similarity index 74% rename from shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt rename to shared/src/commonMain/kotlin/su/reya/coop/Room.kt index eed2744..38d7014 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,5 +1,12 @@ -package su.reya.coop.nostr +package su.reya.coop +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.TimeZone import kotlinx.datetime.minus @@ -75,9 +82,60 @@ data class Room( return this.copy(lastMessage = message) } - fun isGroup(): Boolean { - return members.size > 1 + fun isGroup(): Boolean = members.size > 1 +} + +data class RoomUiState( + val name: String = "Loading...", + val picture: String? = null, + val isGroup: Boolean = false +) + +fun Room.uiStateFlow( + nostrViewModel: NostrViewModel, + currentUser: PublicKey? = null +): Flow { + val displayMembers = if (isGroup()) members.take(2) else members.take(1) + + if (!subject.isNullOrBlank()) { + return flowOf(RoomUiState(name = subject, isGroup = isGroup())) } + + return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles -> + val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() } + + val name = when { + isGroup() -> { + val combined = names.joinToString(", ") + val extra = members.size - names.size + if (extra > 0) "$combined, +$extra" else combined + } + + else -> { + val first = names.firstOrNull() ?: "Unknown" + if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first + } + } + + RoomUiState( + name = name, + picture = profiles.firstOrNull()?.picture, + isGroup = isGroup() + ) + } +} + +@Composable +fun Room.rememberUiState( + viewModel: NostrViewModel, + currentUser: PublicKey? = null +): State { + return remember(this, currentUser) { + uiStateFlow( + viewModel, + currentUser + ) + }.collectAsStateWithLifecycle(RoomUiState()) } fun UnsignedEvent.roomId(): Long { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 157f200..145b728 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync +import su.reya.coop.Room +import su.reya.coop.RoomKind +import su.reya.coop.roomId import kotlin.time.Duration data class MessageSyncState( @@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) { suspend fun getUserMessages(msgRelayList: Event) { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) // Ensure relay connections diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index b873b7a..913c8e0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -136,7 +136,7 @@ class Nostr { fun isSignedByUser(event: Event): Boolean { return try { - signer.currentUser == event.author() + signer.publicKeyFlow.value == event.author() } catch (e: Exception) { println("Failed to check if event is signed by user: ${e.message}") false diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index a326965..778a693 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) { suspend fun getUserMetadata() { try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val author = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") // Get the latest metadata event val metadataFilter = @@ -146,7 +147,8 @@ class ProfileManager(private val nostr: Nostr) { bio: String? = null, picture: String? = null ): Metadata { - val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") try { val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt index bfeef8a..4872ac7 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Signer.kt @@ -1,5 +1,7 @@ package su.reya.coop.nostr +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull @@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { @Volatile private var signer: AsyncNostrSigner = initialSigner - @Volatile - var currentUser: PublicKey? = null - private set + private val _publicKeyFlow = MutableStateFlow(null) + val publicKeyFlow = _publicKeyFlow.asStateFlow() /** * Get the current signer. @@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner { throw IllegalStateException("Failed to get public key from signer", e) } signer = newSigner - currentUser = pubkey + _publicKeyFlow.value = pubkey } override suspend fun getPublicKeyAsync(): PublicKey? {