diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 7730b2c..82ec4e0 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -130,7 +130,6 @@ fun App(dbPath: String) { input.readBytes() } } - viewModel.createIdentity(name, bio, picture, contentType) } ) @@ -142,7 +141,10 @@ fun App(dbPath: String) { } composable { backStackEntry -> val chat: Screen.Chat = backStackEntry.toRoute() - ChatScreen(id = chat.id) + ChatScreen( + id = chat.id, + onBack = { navController.popBackStack() }, + ) } } } 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 bc9f2f3..15264a4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -1,15 +1,233 @@ package su.reya.coop.screens import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_avatar +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.Event +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.shared.displayNameFlow +import su.reya.coop.shared.pictureFlow @Composable -fun ChatScreen(id: Long) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Chat Screen (ID: $id)") +fun ChatScreen( + id: Long, + onBack: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val viewModel = LocalNostrViewModel.current + val room = viewModel.getChatRoom(id) + val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") + val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) + + var text by remember { mutableStateOf("") } + var messages by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + + LaunchedEffect(id) { + loading = true + messages = viewModel.getChatRoomMessages(id) + loading = false + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + if (!picture.isNullOrBlank()) { + AsyncImage( + model = picture, + contentDescription = "Room Avatar", + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(Res.drawable.ic_avatar), + contentDescription = "User" + ) + } + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = displayName, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + ) + }, + content = { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + 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()) + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + reverseLayout = true + ) { + items(messages.toList(), key = { it.id().toBech32() }) { event -> + ChatMessage(event) + } + } + ChatInput( + value = text, + onValueChange = { text = it }, + onSend = { + // TODO: Implement send logic + text = "" + } + ) + } + } + } + } + ) +} + +@Composable +fun ChatMessage( + event: Event +) { + val viewModel = LocalNostrViewModel.current + val currentUser = viewModel.currentUser() + val isMine = event.author() == currentUser + + val bubbleShape = if (isMine) { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) + } else { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp) + } + + val alignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart + val containerColor = + if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer + val contentColor = + if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = alignment + ) { + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = event.content(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +fun ChatInput( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit +) { + Surface(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .imePadding(), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text("Message") }, + modifier = Modifier.weight(1f), + shape = CircleShape, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) + } } } 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 c9ce40d..0e5fd51 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -56,6 +56,8 @@ import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.shared.displayNameFlow +import su.reya.coop.shared.pictureFlow import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -240,37 +242,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val viewModel = LocalNostrViewModel.current - - val memberMetadataList = room.members.map { pubkey -> - viewModel.getMetadata(pubkey).collectAsState() - } - - val displayName = if (!room.subject.isNullOrBlank()) { - room.subject - } else if (room.isGroup()) { - val profiles = memberMetadataList.map { it.value?.asRecord() } - val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName } - - var combined = names.joinToString(", ") - if (profiles.size > 2) { - combined += ", +${profiles.size - 2}" - } - combined.ifBlank { "Unknown group" } - } else { - val firstMember = room.members.firstOrNull() - val profile = memberMetadataList.firstOrNull()?.value?.asRecord() - profile?.name ?: profile?.displayName ?: firstMember?.short() ?: "Unknown" - } - - val firstMemberMetadata by if (room.members.isNotEmpty()) { - viewModel.getMetadata(room.members.first()).collectAsState() - } else { - remember { mutableStateOf(null) } - } - val picture = firstMemberMetadata?.asRecord()?.picture + val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") + val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) ListItem( - modifier = Modifier.clickable { onClick }, + modifier = Modifier.clickable(onClick = onClick), leadingContent = { if (!picture.isNullOrBlank()) { AsyncImage( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt new file mode 100644 index 0000000..5efe323 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/RoomHelper.kt @@ -0,0 +1,33 @@ +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 su.reya.coop.NostrViewModel +import su.reya.coop.Room +import su.reya.coop.short + +fun Room.displayNameFlow(viewModel: NostrViewModel): Flow { + if (!subject.isNullOrBlank()) return flowOf(subject!!) + + val memberFlows = members.map { viewModel.getMetadata(it) } + + return combine(memberFlows) { metadataArray -> + if (isGroup()) { + val profiles = metadataArray.map { it?.asRecord() } + val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName } + var combined = names.joinToString(", ") + if (profiles.size > 2) combined += ", +${profiles.size - 2}" + combined.ifBlank { "Unknown group" } + } else { + val profile = metadataArray.firstOrNull()?.asRecord() + profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown" + } + } +} + +fun Room.pictureFlow(viewModel: NostrViewModel): Flow { + val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null) + return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index b4ab998..aa788f8 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -182,9 +182,8 @@ class Nostr { when (notification) { is ClientNotification.Message -> { val relayUrl = notification.relayUrl - val message = notification.message.asEnum() - - when (message) { + + when (val message = notification.message.asEnum()) { is RelayMessageEnum.EventMsg -> { val event = message.event @@ -396,16 +395,29 @@ class Nostr { } suspend fun fetchMetadataBatch(keys: List) { - val filter = Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(keys.size.toULong()) + try { + val limit = keys.size.toULong(); + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - val metadataRelay = RelayUrl.parse("wss://user.kindpag.es") - val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter))) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + // Construct a filter for metadata events + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(limit) - client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + // Construct a target that includes all filters + val target = + ReqTarget.manual( + mapOf( + RelayUrl.parse("wss://user.kindpag.es") to listOf(filter), + RelayUrl.parse("wss://relay.primal.net") to listOf(filter) + ) + ) + + client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) + } } suspend fun getChatRooms(): Set? { @@ -468,12 +480,11 @@ class Nostr { val sendEvents = client?.database()?.query(sendFilter) val recvEvents = client?.database()?.query(recvFilter) + val events = sendEvents?.merge(recvEvents!!)?.toVec() - sendEvents?.merge(recvEvents!!)?.toVec() + return events ?: emptyList() } catch (e: Exception) { throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) } - - return emptyList() } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 2b472f7..97e590c 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -80,7 +80,7 @@ class NostrViewModel( } val now = Clock.System.now().toEpochMilliseconds() - if (batch.size >= 20 || (now - lastFlushTime) >= timeout || nextKey == null) { + if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) { val keysToRequest = batch.toList() batch.clear() nostr.fetchMetadataBatch(keysToRequest) @@ -271,6 +271,11 @@ class NostrViewModel( } } + fun getChatRoom(id: Long): Room { + return chatRooms.value.firstOrNull { it.id == id } + ?: throw IllegalArgumentException("Room not found") + } + fun getChatRooms() { viewModelScope.launch { try {