add highlighted message
This commit is contained in:
@@ -5,7 +5,7 @@ import androidx.compose.animation.expandVertically
|
|||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
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.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -26,8 +26,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
@@ -103,8 +107,9 @@ fun rememberMessageUiModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessage(model: MessageUiModel) {
|
fun ChatMessage(model: MessageUiModel, onLongClick: (Rect) -> Unit = {}) {
|
||||||
var isMessageClicked by remember { mutableStateOf(false) }
|
var isMessageClicked by remember { mutableStateOf(false) }
|
||||||
|
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
|
|
||||||
val bubbleShape = if (model.isMine) {
|
val bubbleShape = if (model.isMine) {
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
@@ -121,16 +126,21 @@ fun ChatMessage(model: MessageUiModel) {
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp)
|
||||||
|
.onGloballyPositioned { layoutCoordinates = it },
|
||||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.combinedClickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null,
|
||||||
) {
|
onClick = { isMessageClicked = !isMessageClicked },
|
||||||
isMessageClicked = !isMessageClicked
|
onLongClick = {
|
||||||
},
|
layoutCoordinates?.let { coords ->
|
||||||
|
onLongClick(coords.boundsInWindow())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import android.net.Uri
|
|||||||
import android.speech.RecognizerIntent
|
import android.speech.RecognizerIntent
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.union
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -51,8 +61,14 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
@@ -112,6 +128,8 @@ fun ChatScreen(
|
|||||||
.collectAsStateWithLifecycle(RoomUiState())
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
|
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) }
|
||||||
|
|
||||||
val loading = viewModel.loading
|
val loading = viewModel.loading
|
||||||
val newOtherMessages = viewModel.newOtherMessages
|
val newOtherMessages = viewModel.newOtherMessages
|
||||||
val requireScreening = viewModel.requireScreening
|
val requireScreening = viewModel.requireScreening
|
||||||
@@ -120,6 +138,11 @@ fun ChatScreen(
|
|||||||
val groupedMessages =
|
val groupedMessages =
|
||||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
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 ->
|
val sendFile = { uri: Uri ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Read file on IO dispatcher
|
// Read file on IO dispatcher
|
||||||
@@ -149,16 +172,17 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.blur(blurAmount),
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -253,7 +277,12 @@ fun ChatScreen(
|
|||||||
currentUserPublicKey = currentUser?.publicKey,
|
currentUserPublicKey = currentUser?.publicKey,
|
||||||
contentColor = if (isMine) mineColor else otherColor
|
contentColor = if (isMine) mineColor else otherColor
|
||||||
)
|
)
|
||||||
ChatMessage(model = uiModel)
|
ChatMessage(
|
||||||
|
model = uiModel,
|
||||||
|
onLongClick = { rect ->
|
||||||
|
selectedMessage = uiModel to rect
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
@@ -341,7 +370,10 @@ fun ChatScreen(
|
|||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
)
|
)
|
||||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_PROMPT,
|
||||||
|
"Speak now..."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
sttLauncher.launch(intent)
|
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
|
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)
|
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 {
|
fun String.removeImageUrls(): String {
|
||||||
return URL_REGEX.replace(this) { result ->
|
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
|
||||||
if (result.value.isImageUrl()) "" else result.value
|
|
||||||
}.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.isImageUrl(): Boolean {
|
fun String.isImageUrl(): Boolean {
|
||||||
@@ -24,3 +17,8 @@ fun String.isImageUrl(): Boolean {
|
|||||||
fun String.sanitizeName(): String {
|
fun String.sanitizeName(): String {
|
||||||
return this.replace("\n", " ").replace("\r", " ").trim()
|
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