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 b945c7e..e5515a3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_send +import kotlinx.coroutines.flow.first import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNostrViewModel @@ -56,6 +57,7 @@ import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.displayNameFlow import su.reya.coop.shared.pictureFlow +import su.reya.coop.short @Composable fun ChatScreen( @@ -91,7 +93,16 @@ fun ChatScreen( messages.addAll(initialMessages) // Get msg relays for each member - viewModel.chatRoomConnect(id) + val results = viewModel.chatRoomConnect(id) + results.forEach { (member, relays) -> + if (relays.isNotEmpty()) { + val metadata = viewModel.getMetadata(member).first { it != null } + val profile = metadata?.asRecord() + val name = profile?.displayName ?: profile?.name ?: member.short() + + snackbarHostState.showSnackbar("Connected to messaging relays for $name") + } + } // Stop loading spinner setLoading(false) @@ -113,7 +124,11 @@ fun ChatScreen( TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { - Box { + if (loading) { + LoadingIndicator( + modifier = Modifier.size(32.dp), + ) + } else { Avatar( picture = picture, description = displayName, @@ -148,19 +163,12 @@ fun ChatScreen( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) { - if (loading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - LoadingIndicator() - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(bottom = innerPadding.calculateBottomPadding()) - ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = innerPadding.calculateBottomPadding()) + ) { + if (groupedMessages.isNotEmpty()) { LazyColumn( modifier = Modifier .weight(1f) @@ -169,7 +177,9 @@ fun ChatScreen( reverseLayout = true ) { groupedMessages.forEach { (dateHeader, messagesInGroup) -> - items(messagesInGroup, key = { it.id()?.toBech32()!! }) { event -> + items( + messagesInGroup, + key = { it.id()?.toBech32()!! }) { event -> ChatMessage(event) } item { @@ -177,15 +187,33 @@ fun ChatScreen( } } } - ChatInput( - value = text, - onValueChange = { text = it }, - onSend = { - viewModel.sendMessage(id, text) - text = "" + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No messages yet", + style = MaterialTheme.typography.titleLargeEmphasized, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Your conversations will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) } - ) + } } + ChatInput( + value = text, + onValueChange = { text = it }, + onSend = { + viewModel.sendMessage(id, text) + text = "" + } + ) } } } @@ -223,10 +251,10 @@ fun ChatMessage( } val containerColor = - if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer + if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer val contentColor = - if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer + if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer Box( modifier = Modifier 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 a3ca0f0..e7bc625 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -360,19 +360,19 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { ) } -val defaultMenuList = listOf( - "Messaging Relays", - "Spam Filter", - "Contacts", - "Settings", - "About" -) - @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BottomMenuList() { val viewModel = LocalNostrViewModel.current + val defaultMenuList = listOf( + "Messaging Relays" to { }, + "Spam Filter" to { }, + "Contacts" to { }, + "Settings" to { }, + "About" to { } + ) + Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -381,15 +381,14 @@ fun BottomMenuList() { modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { - defaultMenuList.forEachIndexed { index, item -> + defaultMenuList.forEachIndexed { index, (title, action) -> SegmentedListItem( - checked = false, - onCheckedChange = { }, + onClick = { action() }, shapes = ListItemDefaults.segmentedShapes( index = index, count = defaultMenuList.size ), - content = { Text(text = item) }, + content = { Text(text = title) }, ) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index a7fc1a5..afe37c0 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -70,8 +70,6 @@ class Nostr { private set var deviceSigner: AsyncNostrSigner? = null private set - var msgRelayList: Map> = emptyMap() - private set var sentEvents: MutableMap> = mutableMapOf() private set var rumorMap: MutableMap = mutableMapOf() @@ -253,11 +251,15 @@ class Nostr { } } - else -> {} + else -> { + /* Ignore other event kinds */ + } } } - else -> {} + else -> { + /* Ignore other message types */ + } } } } @@ -306,20 +308,10 @@ class Nostr { } if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { - // Get all gift wrap events for current user + // Get all gift wrap events for the current user if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } - - // Connect to all msg relays for the currently active chat room - if (id.startsWith("room-")) { - launch { - chatRoomAuth(event) - } - } - - // Cache the relay list for future use - setMsgRelay(pubkey = event.author(), event = event) } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { @@ -368,22 +360,17 @@ class Nostr { } } - is ClientNotification.NewEvent -> { - // TODO: Handle new event - } - is ClientNotification.Shutdown -> { break } + + else -> { + /* Ignore other message types */ + } } } } - private fun setMsgRelay(pubkey: PublicKey, event: Event) { - val relays = nip17ExtractRelayList(event) - msgRelayList = msgRelayList + (pubkey to relays) - } - private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { try { val filter = Filter().identifier(giftId.toHex()) @@ -644,33 +631,49 @@ class Nostr { } } - suspend fun chatRoomConnect(id: Long, members: List) { + suspend fun chatRoomConnect(members: List): Map> { try { + val results = mutableMapOf>() + members.forEach { member -> + results[member] = mutableListOf() val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val filter = Filter().kind(kind).author(member).limit(1u) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - client?.subscribe( + val stream = client?.streamEvents( target = ReqTarget.auto(listOf(filter)), - closeOn = opts, - id = "room-${id}" + id = "room-${member.toBech32().substring(0, 10)}", + timeout = Duration.parse("3s"), + policy = ReqExitPolicy.ExitOnEose ) + + stream?.next()?.let { res -> + if (res.event != null) { + // Connect to the msg relays + connectMsgRelays(res.event!!) + // Mark the member as connected + results[member]?.add(res.relayUrl) + } + } } + + return results } catch (e: Exception) { - throw IllegalStateException("Failed to connect to chat room: ${e.message}", e) + throw IllegalStateException("Failed to fetch relays: ${e.message}", e) } } - suspend fun chatRoomAuth(event: Event) { + suspend fun connectMsgRelays(event: Event) { try { val urls = nip17ExtractRelayList(event); for (url in urls) { - client?.addRelay(url) - client?.connectRelay(url) + if (client?.relay(url) == null) { + client?.addRelay(url) + client?.connectRelay(url) + } } } catch (e: Exception) { - throw IllegalStateException("Failed to authenticate chat room: ${e.message}", e) + throw IllegalStateException("Failed to connect to relays: ${e.message}", e) } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 4dd2ff1..b604736 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -373,16 +373,15 @@ class NostrViewModel( return emptyList() } - fun chatRoomConnect(roomId: Long) { - viewModelScope.launch { - try { - val room = getChatRoom(roomId) - val members = room.members + suspend fun chatRoomConnect(roomId: Long): Map> { + val room = getChatRoom(roomId) + val members = room.members - nostr.chatRoomConnect(roomId, members.toList()) - } catch (e: Exception) { - showError("Error: ${e.message}") - } + return runCatching { + nostr.chatRoomConnect(members.toList()) + }.getOrElse { e -> + showError("Error: ${e.message}") + members.associateWith { emptyList() } } }