add highlighted message

This commit is contained in:
2026-07-13 09:38:54 +07:00
parent 410615bd89
commit 6beaf05f1c
3 changed files with 320 additions and 196 deletions

View File

@@ -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)
) { ) {

View File

@@ -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,213 +172,306 @@ fun ChatScreen(
} }
} }
LaunchedEffect(id) {
}
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
} }
Scaffold( Box(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), modifier = Modifier.fillMaxSize()
containerColor = MaterialTheme.colorScheme.surfaceContainer, ) {
snackbarHost = { SnackbarHost(snackbarHostState) }, Scaffold(
topBar = { modifier = Modifier.blur(blurAmount),
TopAppBar( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
title = { containerColor = MaterialTheme.colorScheme.surfaceContainer,
Row( snackbarHost = { SnackbarHost(snackbarHostState) },
verticalAlignment = Alignment.CenterVertically, topBar = {
modifier = Modifier.clickable { TopAppBar(
room?.members?.firstOrNull()?.let { pubkey -> title = {
navigator.navigate(Screen.Profile(pubkey.toBech32())) Row(
} verticalAlignment = Alignment.CenterVertically,
} modifier = Modifier.clickable {
) { room?.members?.firstOrNull()?.let { pubkey ->
if (loading) { navigator.navigate(Screen.Profile(pubkey.toBech32()))
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())
} }
} }
} ) {
) { if (loading) {
IconButton(onClick = { navigator.goBack() }) { LoadingIndicator(modifier = Modifier.size(32.dp))
Icon( } else {
painter = painterResource(Res.drawable.ic_arrow_back), Avatar(
contentDescription = "Back" picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
} },
}, navigationIcon = {
colors = TopAppBarDefaults.topAppBarColors( BadgedBox(
containerColor = MaterialTheme.colorScheme.surfaceContainer, 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 ->
content = { innerPadding -> Surface(
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
if (requireScreening) { Column(
room?.let { ScreenerCard(accountViewModel, it) } modifier = Modifier
} .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer ) {
val otherColor = MaterialTheme.colorScheme.onSurface if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) }
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)
}
}
}
} }
false -> { val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
Box( val otherColor = MaterialTheme.colorScheme.onSurface
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) { when (messages.isNotEmpty()) {
true -> { true -> {
Row( LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.size(ButtonDefaults.MediumContainerHeight) .fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) { ) {
Text( groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
text = "Reject", items(
style = MaterialTheme.typography.titleMedium, items = messagesInGroup,
) key = { it.id()?.toHex() ?: it.hashCode().toString() }
} ) { event ->
FilledTonalButton( val isMine = currentUser?.publicKey == event.author()
onClick = { viewModel.requireScreening = false }, val uiModel = rememberMessageUiModel(
modifier = Modifier event = event,
.weight(1f) currentUserPublicKey = currentUser?.publicKey,
.size(ButtonDefaults.MediumContainerHeight) contentColor = if (isMine) mineColor else otherColor
) { )
Text( ChatMessage(
text = "Accept", model = uiModel,
style = MaterialTheme.typography.titleMedium, onLongClick = { rect ->
) selectedMessage = uiModel to rect
} }
}
}
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 { item {
sttLauncher.launch(intent) DateSeparator(dateHeader)
} catch (e: Exception) {
scope.launch {
snackbarHostState.showSnackbar("Speech recognition not available")
} }
} }
} }
) }
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()
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 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)
}