feat: add context menu for chat message (#43)

Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/43
This commit is contained in:
2026-07-13 10:56:23 +00:00
parent 410615bd89
commit 0e789b5d91
6 changed files with 394 additions and 194 deletions
@@ -5,7 +5,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,8 +26,12 @@ 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.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
@@ -103,8 +107,13 @@ fun rememberMessageUiModel(
}
@Composable
fun ChatMessage(model: MessageUiModel) {
fun ChatMessage(
model: MessageUiModel,
modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {}
) {
var isMessageClicked by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
val bubbleShape = if (model.isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
@@ -119,18 +128,23 @@ fun ChatMessage(model: MessageUiModel) {
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
Box(
modifier = Modifier
modifier = modifier
.onGloballyPositioned { layoutCoordinates = it }
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
modifier = Modifier.clickable(
modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
isMessageClicked = !isMessageClicked
},
indication = null,
onClick = { isMessageClicked = !isMessageClicked },
onLongClick = {
layoutCoordinates?.let { coords ->
onLongClick(coords.boundsInWindow())
}
}
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -1,11 +1,17 @@
package su.reya.coop.screens.chat
import android.app.Activity
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.speech.RecognizerIntent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -16,24 +22,33 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
@@ -45,18 +60,32 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.blur
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_copy
import coop.composeapp.generated.resources.ic_info
import coop.composeapp.generated.resources.ic_reply
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -83,6 +112,7 @@ fun ChatScreen(
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val navigator = LocalNavigator.current
val profileCache = LocalProfileCache.current
val scope = rememberCoroutineScope()
@@ -112,6 +142,8 @@ fun ChatScreen(
.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) }
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
@@ -120,6 +152,11 @@ fun ChatScreen(
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
val blurAmount by animateDpAsState(
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
label = "blurAnimation"
)
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
@@ -149,213 +186,336 @@ fun ChatScreen(
}
}
LaunchedEffect(id) {
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
room?.members?.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32()))
}
}
) {
if (loading) {
LoadingIndicator(modifier = Modifier.size(32.dp))
} else {
Avatar(
picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
},
navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
Box(
modifier = Modifier.fillMaxSize()
) {
Scaffold(
modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
room?.members?.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32()))
}
}
}
) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
) {
if (loading) {
LoadingIndicator(modifier = Modifier.size(32.dp))
} else {
Avatar(
picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
},
navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = { navigator.goBack() }) {
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(
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) {
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.id()?.toHex() ?: it.hashCode().toString() }
) { event ->
val isMine = currentUser?.publicKey == event.author()
val uiModel = rememberMessageUiModel(
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
ChatMessage(model = uiModel)
}
item {
DateSeparator(dateHeader)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
false -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (requireScreening) {
true -> {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) {
Text(
text = "Reject",
style = MaterialTheme.typography.titleMedium,
)
}
FilledTonalButton(
onClick = { viewModel.requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.id()?.toHex() ?: it.hashCode().toString() }
) { event ->
val isMine = currentUser?.publicKey == event.author()
val uiModel = rememberMessageUiModel(
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
val isHighlighted =
selectedMessage?.first?.id == uiModel.id
else -> {
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(text)
text = ""
},
onUpload = {
fileLauncher.launch("image/*")
},
onMicClick = {
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
ChatMessage(
model = uiModel,
modifier = Modifier.graphicsLayer {
alpha = if (isHighlighted) 0f else 1f
},
onLongClick = { rect ->
selectedMessage = uiModel to rect
}
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
}
try {
sttLauncher.launch(intent)
} catch (e: Exception) {
scope.launch {
snackbarHostState.showSnackbar("Speech recognition not available")
item {
DateSeparator(dateHeader)
}
}
}
)
}
false -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
when (requireScreening) {
true -> {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Reject",
style = MaterialTheme.typography.titleMedium,
)
}
FilledTonalButton(
onClick = { viewModel.requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
else -> {
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(text)
text = ""
},
onUpload = {
fileLauncher.launch("image/*")
},
onMicClick = {
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(
RecognizerIntent.EXTRA_PROMPT,
"Speak now..."
)
}
try {
sttLauncher.launch(intent)
} catch (e: Exception) {
scope.launch {
snackbarHostState.showSnackbar("Speech recognition not available")
}
}
}
)
}
}
}
}
}
)
AnimatedVisibility(
visible = selectedMessage != null,
enter = fadeIn(),
exit = fadeOut()
) {
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 12.dp.toPx() }
val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
ChatMessage(
model = model,
modifier = Modifier
.offset { IntOffset(0, bounds.top.toInt()) }
.padding(horizontal = 16.dp)
)
val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box(
modifier = Modifier
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu { action ->
when (action) {
"Copy" -> {
scope.launch {
val content = model.annotatedContent
val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
else -> {}
}
selectedMessage = null
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(onAction: (String) -> Unit) {
val menuItems = listOf(
Triple("Copy", Res.drawable.ic_copy, false),
Triple("Reply", Res.drawable.ic_reply, false),
Triple("Info", Res.drawable.ic_info, false),
)
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon, hasDivider) ->
DropdownMenuItem(
shapes = MenuDefaults.itemShape(index, itemCount),
colors = MenuDefaults.selectableItemVibrantColors(),
text = { Text(label) },
leadingIcon = {
Icon(
painter = painterResource(icon),
contentDescription = label,
)
},
checked = false,
onCheckedChange = { _ -> onAction(label) },
)
if (hasDivider) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
}
}