This commit is contained in:
2026-05-19 00:10:38 +02:00
parent db2290ba14
commit c68787cd01
93 changed files with 5855 additions and 0 deletions
@@ -0,0 +1,563 @@
/*
* 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<Message>, 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