feat: add context menu for chat message #43

Merged
reya merged 4 commits from feat/context-menu into master 2026-07-13 10:56:23 +00:00
3 changed files with 320 additions and 196 deletions
Showing only changes of commit 6beaf05f1c - Show all commits

View File

@@ -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,9 @@ fun rememberMessageUiModel(
}
@Composable
fun ChatMessage(model: MessageUiModel) {
fun ChatMessage(model: MessageUiModel, 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)
@@ -121,16 +126,21 @@ fun ChatMessage(model: MessageUiModel) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
.padding(vertical = 4.dp)
.onGloballyPositioned { layoutCoordinates = it },
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)
) {

View File

@@ -6,6 +6,11 @@ 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,14 +21,19 @@ 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
@@ -51,8 +61,14 @@ 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.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
@@ -112,6 +128,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 +138,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,16 +172,17 @@ fun ChatScreen(
}
}
LaunchedEffect(id) {
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
Scaffold(
modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -253,7 +277,12 @@ fun ChatScreen(
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
ChatMessage(model = uiModel)
ChatMessage(
model = uiModel,
onLongClick = { rect ->
selectedMessage = uiModel to rect
}
)
}
item {
DateSeparator(dateHeader)
@@ -341,7 +370,10 @@ fun ChatScreen(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
putExtra(
RecognizerIntent.EXTRA_PROMPT,
"Speak now..."
)
}
try {
sttLauncher.launch(intent)
@@ -358,4 +390,88 @@ fun ChatScreen(
}
}
)
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()
val menuHeightDp = 336.dp
val spacingDp = 12.dp
val totalExtraHeightPx = with(density) { (menuHeightDp + spacingDp).toPx() }
val showAbove =
(windowHeight - bounds.bottom) < totalExtraHeightPx && bounds.top > totalExtraHeightPx
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val contentBottomDp = with(density) { (bounds.bottom + totalExtraHeightPx).toDp() }
Spacer(modifier = Modifier.height(contentBottomDp + 200.dp))
Column(
modifier = Modifier
.offset {
IntOffset(
0,
if (showAbove) (bounds.top - totalExtraHeightPx).toInt()
.coerceAtLeast(0) else bounds.top.toInt()
)
}
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (showAbove) {
ContextMenu { selectedMessage = null }
ChatMessage(model = model)
} else {
ChatMessage(model = model)
ContextMenu { selectedMessage = null }
}
}
}
}
}
}
@Composable
private fun ContextMenu(onAction: (String) -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.width(220.dp)
) {
Column {
listOf(
"Reply",
"Forward",
"Copy",
"Select",
"Info",
"Pin",
"Delete"
).forEach { action ->
Text(
text = action,
modifier = Modifier
.fillMaxWidth()
.clickable { onAction(action) }
.padding(horizontal = 16.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

View File

@@ -2,18 +2,11 @@ package su.reya.coop
import rust.nostr.sdk.PublicKey
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
fun String.removeImageUrls(): String {
return URL_REGEX.replace(this) { result ->
if (result.value.isImageUrl()) "" else result.value
}.trim()
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
}
fun String.isImageUrl(): Boolean {
@@ -24,3 +17,8 @@ fun String.isImageUrl(): Boolean {
fun String.sanitizeName(): String {
return this.replace("\n", " ").replace("\r", " ").trim()
}
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}