package su.reya.coop.screens import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults 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.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf 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.graphics.Color 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.LocalNavController import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen import su.reya.coop.formatAsGroupHeader 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( id: Long, onBack: () -> Unit, ) { val snackbarHostState = LocalSnackbarHostState.current val navController = LocalNavController.current val viewModel = LocalNostrViewModel.current val listState = rememberLazyListState() val chatRooms by viewModel.chatRooms.collectAsState() val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } } if (room == null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { LoadingIndicator() } return } 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 loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } val messages = remember { mutableStateListOf() } val groupedMessages = remember(messages.toList()) { messages.groupBy { it.createdAt().formatAsGroupHeader() } } LaunchedEffect(id) { // Start loading spinner loading = true // Get messages val initialMessages = viewModel.getChatRoomMessages(id) messages.clear() messages.addAll(initialMessages) // Get msg relays for each member 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 loading = false // Handle new messages viewModel.newEvents.collect { event -> if (event.roomId() == id) { if (event.id() !in messages.map { it.id() }) { messages.add(0, event) } } else { // If the event is not in the current room, it's a new message from another user newOtherMessages++ } } } LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { listState.animateScrollToItem(0) } } Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { room.members.firstOrNull()?.let { pubkey -> navController.navigate(Screen.Profile(pubkey.toBech32())) } } ) { if (loading) { LoadingIndicator( modifier = Modifier.size(32.dp), ) } else { Avatar( picture = picture, description = displayName, size = 32.dp, ) } Spacer(modifier = Modifier.size(8.dp)) Text( text = displayName, style = MaterialTheme.typography.titleMediumEmphasized, ) } }, navigationIcon = { BadgedBox( badge = { if (newOtherMessages > 0) { Badge { Text(newOtherMessages.toString()) } } } ) { 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), ) { Column( modifier = Modifier .fillMaxSize() .padding(bottom = innerPadding.calculateBottomPadding()) ) { if (messages.isNotEmpty()) { LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth(), contentPadding = PaddingValues(16.dp), reverseLayout = true, state = listState, ) { groupedMessages.forEach { (dateHeader, messagesInGroup) -> items( messagesInGroup, key = { it.id()?.toBech32()!! }) { event -> ChatMessage(event) } item { DateSeparator(dateHeader) } } } } else { Box( modifier = Modifier .weight(1f) .fillMaxWidth(), 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 = "" } ) } } } ) } @Composable fun DateSeparator(date: String) { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { Text( text = date, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline ) } } @Composable fun ChatMessage( rumor: UnsignedEvent ) { val viewModel = LocalNostrViewModel.current val currentUser = viewModel.currentUser() val isMine = rumor.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 containerColor = if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer val contentColor = if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart ) { Column( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start ) { Surface( color = containerColor, contentColor = contentColor, shape = bubbleShape, modifier = Modifier .widthIn(max = 280.dp) .clickable( onClick = { val id = rumor.id() if (id != null) { val sent = viewModel.isMessageSent(id) println("Sent: $sent") } } ) ) { Text( text = rumor.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) .height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom ) { TextField( value = value, onValueChange = onValueChange, placeholder = { Text("Message") }, shape = RoundedCornerShape(28.dp), colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent ), modifier = Modifier.weight(1f) ) Spacer(modifier = Modifier.size(8.dp)) FilledTonalIconButton( onClick = onSend, modifier = Modifier .fillMaxHeight() .aspectRatio(1f), colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) ) { Icon( painter = painterResource(Res.drawable.ic_send), contentDescription = "Send" ) } } } }