/* * Copyright 2026 MagicalBits * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ @file:OptIn(ExperimentalMaterial3Api::class) package xyz.magicalbits.smsremote.conversation import android.content.ClipDescription import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.paddingFrom import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup import xyz.magicalbits.smsremote.R import xyz.magicalbits.smsremote.components.JetchatAppBar import xyz.magicalbits.smsremote.data.exampleUiState import xyz.magicalbits.smsremote.theme.JetchatTheme import kotlinx.coroutines.launch /** * Entry point for a conversation screen. * * @param uiState [ConversationUiState] that contains messages to display * @param navigateToProfile User action when navigation to a profile is requested * @param modifier [Modifier] to apply to this layout node * @param onNavIconPressed Sends an event up when the user clicks on the menu */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ConversationContent( uiState: ConversationUiState, navigateToProfile: (String) -> Unit, modifier: Modifier = Modifier, onNavIconPressed: () -> Unit = { }, ) { val authorMe = stringResource(R.string.author_me) val timeNow = stringResource(id = R.string.now) val scrollState = rememberLazyListState() val topBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState) val scope = rememberCoroutineScope() var background by remember { mutableStateOf(Color.Transparent) } var borderStroke by remember { mutableStateOf(Color.Transparent) } val dragAndDropCallback = remember { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { val clipData = event.toAndroidDragEvent().clipData if (clipData.itemCount < 1) { return false } uiState.addMessage( Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow), ) return true } override fun onStarted(event: DragAndDropEvent) { super.onStarted(event) borderStroke = Color.Red } override fun onEntered(event: DragAndDropEvent) { super.onEntered(event) background = Color.Red.copy(alpha = .3f) } override fun onExited(event: DragAndDropEvent) { super.onExited(event) background = Color.Transparent } override fun onEnded(event: DragAndDropEvent) { super.onEnded(event) background = Color.Transparent borderStroke = Color.Transparent } } } Scaffold( topBar = { ChannelNameBar( channelName = uiState.channelName, channelMembers = uiState.channelMembers, onNavIconPressed = onNavIconPressed, scrollBehavior = scrollBehavior, ) }, // Exclude ime and navigation bar padding so this can be added by the UserInput composable contentWindowInsets = ScaffoldDefaults .contentWindowInsets .exclude(WindowInsets.navigationBars) .exclude(WindowInsets.ime), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( Modifier.fillMaxSize().padding(paddingValues) .background(color = background) .border(width = 2.dp, color = borderStroke) .dragAndDropTarget(shouldStartDragAndDrop = { event -> event .mimeTypes() .contains( ClipDescription.MIMETYPE_TEXT_PLAIN, ) }, target = dragAndDropCallback), ) { Messages( messages = uiState.messages, navigateToProfile = navigateToProfile, modifier = Modifier.weight(1f), scrollState = scrollState, ) UserInput( onMessageSent = { content -> uiState.addMessage( Message(authorMe, content, timeNow), ) }, resetScroll = { scope.launch { scrollState.scrollToItem(0) } }, // let this element handle the padding so that the elevation is shown behind the // navigation bar modifier = Modifier.navigationBarsPadding().imePadding(), ) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelNameBar( channelName: String, channelMembers: Int, modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior? = null, onNavIconPressed: () -> Unit = { }, ) { var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) } if (functionalityNotAvailablePopupShown) { FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false } } JetchatAppBar( modifier = modifier, scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, title = { Column(horizontalAlignment = Alignment.CenterHorizontally) { // Channel name Text( text = channelName, style = MaterialTheme.typography.titleMedium, ) // Number of members Text( text = stringResource(R.string.members, channelMembers), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, actions = { // Search icon Icon( painterResource(id = R.drawable.ic_search), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .clickable(onClick = { functionalityNotAvailablePopupShown = true }) .padding(horizontal = 12.dp, vertical = 16.dp) .height(24.dp), contentDescription = stringResource(id = R.string.search), ) // Info icon Icon( painterResource(id = R.drawable.ic_info), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .clickable(onClick = { functionalityNotAvailablePopupShown = true }) .padding(horizontal = 12.dp, vertical = 16.dp) .height(24.dp), contentDescription = stringResource(id = R.string.info), ) }, ) } const val ConversationTestTag = "ConversationTestTag" @Composable fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() Box(modifier = modifier) { val authorMe = stringResource(id = R.string.author_me) LazyColumn( reverseLayout = true, state = scrollState, modifier = Modifier .testTag(ConversationTestTag) .fillMaxSize(), ) { for (index in messages.indices) { val prevAuthor = messages.getOrNull(index - 1)?.author val nextAuthor = messages.getOrNull(index + 1)?.author val content = messages[index] val isFirstMessageByAuthor = prevAuthor != content.author val isLastMessageByAuthor = nextAuthor != content.author // Hardcode day dividers for simplicity if (index == messages.size - 1) { item { DayHeader("20 Aug") } } else if (index == 2) { item { DayHeader("Today") } } item { Message( onAuthorClick = { name -> navigateToProfile(name) }, msg = content, isUserMe = content.author == authorMe, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, ) } } } // Jump to bottom button shows up when user scrolls past a threshold. // Convert to pixels: val jumpThreshold = with(LocalDensity.current) { JumpToBottomThreshold.toPx() } // Show the button if the first visible item is not the first one or if the offset is // greater than the threshold. val jumpToBottomButtonEnabled by remember { derivedStateOf { scrollState.firstVisibleItemIndex != 0 || scrollState.firstVisibleItemScrollOffset > jumpThreshold } } JumpToBottom( // Only show if the scroller is not at the bottom enabled = jumpToBottomButtonEnabled, onClicked = { scope.launch { scrollState.animateScrollToItem(0) } }, modifier = Modifier.align(Alignment.BottomCenter), ) } } @Composable fun Message( onAuthorClick: (String) -> Unit, msg: Message, isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, ) { val borderColor = if (isUserMe) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.tertiary } val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier Row(modifier = spaceBetweenAuthors) { if (isLastMessageByAuthor) { // Avatar Image( modifier = Modifier .clickable(onClick = { onAuthorClick(msg.author) }) .padding(horizontal = 16.dp) .size(42.dp) .border(1.5.dp, borderColor, CircleShape) .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) .clip(CircleShape) .align(Alignment.Top), painter = painterResource(id = msg.authorImage), contentScale = ContentScale.Crop, contentDescription = null, ) } else { // Space under avatar Spacer(modifier = Modifier.width(74.dp)) } AuthorAndTextMessage( msg = msg, isUserMe = isUserMe, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, authorClicked = onAuthorClick, modifier = Modifier .padding(end = 16.dp) .weight(1f), ) } } @Composable fun AuthorAndTextMessage( msg: Message, isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, authorClicked: (String) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { if (isLastMessageByAuthor) { AuthorNameTimestamp(msg) } ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) if (isFirstMessageByAuthor) { // Last bubble before next author Spacer(modifier = Modifier.height(8.dp)) } else { // Between bubbles Spacer(modifier = Modifier.height(4.dp)) } } } @Composable private fun AuthorNameTimestamp(msg: Message) { // Combine author and timestamp for a11y. Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { Text( text = msg.author, style = MaterialTheme.typography.titleMedium, modifier = Modifier .alignBy(LastBaseline) .paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble ) Spacer(modifier = Modifier.width(8.dp)) Text( text = msg.timestamp, style = MaterialTheme.typography.bodySmall, modifier = Modifier.alignBy(LastBaseline), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) @Composable fun DayHeader(dayString: String) { Row( modifier = Modifier .padding(vertical = 8.dp, horizontal = 16.dp) .height(16.dp), ) { DayHeaderLine() Text( text = dayString, modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) DayHeaderLine() } } @Composable private fun RowScope.DayHeaderLine() { HorizontalDivider( modifier = Modifier .weight(1f) .align(Alignment.CenterVertically), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), ) } @Composable fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { val backgroundBubbleColor = if (isUserMe) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.surfaceVariant } Column { Surface( color = backgroundBubbleColor, shape = ChatBubbleShape, ) { ClickableMessage( message = message, isUserMe = isUserMe, authorClicked = authorClicked, ) } message.image?.let { Spacer(modifier = Modifier.height(4.dp)) Surface( color = backgroundBubbleColor, shape = ChatBubbleShape, ) { Image( painter = painterResource(it), contentScale = ContentScale.Fit, modifier = Modifier.size(160.dp), contentDescription = stringResource(id = R.string.attached_image), ) } } } } @Composable fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { val uriHandler = LocalUriHandler.current val styledMessage = messageFormatter( text = message.content, primary = isUserMe, ) ClickableText( text = styledMessage, style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), modifier = Modifier.padding(16.dp), onClick = { styledMessage .getStringAnnotations(start = it, end = it) .firstOrNull() ?.let { annotation -> when (annotation.tag) { SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item) SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item) else -> Unit } } }, ) } @Preview @Composable fun ConversationPreview() { JetchatTheme { ConversationContent( uiState = exampleUiState, navigateToProfile = { }, ) } } @Preview @Composable fun ChannelBarPrev() { JetchatTheme { ChannelNameBar(channelName = "composers", channelMembers = 52) } } @Preview @Composable fun DayHeaderPrev() { DayHeader("Aug 6") } private val JumpToBottomThreshold = 56.dp