feat: add context menu for chat message #43
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user