From 6beaf05f1c3e64e295a295dcd2c7f5cfbd1e8d82 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 13 Jul 2026 09:38:54 +0700 Subject: [PATCH 1/4] add highlighted message --- .../su/reya/coop/screens/chat/ChatMessage.kt | 26 +- .../su/reya/coop/screens/chat/ChatScreen.kt | 474 +++++++++++------- .../kotlin/su/reya/coop/Extensions.kt | 16 +- 3 files changed, 320 insertions(+), 196 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt index 348bdce..07b97bc 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -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(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) ) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 3c92a6b..d445377 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -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?>(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,213 +172,306 @@ 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, - ) - } - } - } - - 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 + 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, + 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() + + 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 + ) + } + } + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt index 5d5151e..9e321ff 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt @@ -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) +} -- 2.49.1 From 2e290cce0c6600bd6cf2cdf2cdff1e7d322baf2f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 13 Jul 2026 09:55:35 +0700 Subject: [PATCH 2/4] update message position --- .../su/reya/coop/screens/chat/ChatMessage.kt | 12 ++++-- .../su/reya/coop/screens/chat/ChatScreen.kt | 41 +++++++++++-------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt index 07b97bc..6b67afd 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -107,7 +107,11 @@ fun rememberMessageUiModel( } @Composable -fun ChatMessage(model: MessageUiModel, onLongClick: (Rect) -> Unit = {}) { +fun ChatMessage( + model: MessageUiModel, + modifier: Modifier = Modifier, + onLongClick: (Rect) -> Unit = {} +) { var isMessageClicked by remember { mutableStateOf(false) } var layoutCoordinates by remember { mutableStateOf(null) } @@ -124,10 +128,10 @@ fun ChatMessage(model: MessageUiModel, onLongClick: (Rect) -> Unit = {}) { if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer Box( - modifier = Modifier + modifier = modifier + .onGloballyPositioned { layoutCoordinates = it } .fillMaxWidth() - .padding(vertical = 4.dp) - .onGloballyPositioned { layoutCoordinates = it }, + .padding(vertical = 4.dp), contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart ) { Column( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index d445377..3670449 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -64,6 +64,7 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo @@ -277,8 +278,14 @@ fun ChatScreen( currentUserPublicKey = currentUser?.publicKey, contentColor = if (isMine) mineColor else otherColor ) + val isHighlighted = + selectedMessage?.first?.id == uiModel.id + ChatMessage( model = uiModel, + modifier = Modifier.graphicsLayer { + alpha = if (isHighlighted) 0f else 1f + }, onLongClick = { rect -> selectedMessage = uiModel to rect } @@ -419,27 +426,27 @@ fun ChatScreen( val contentBottomDp = with(density) { (bounds.bottom + totalExtraHeightPx).toDp() } Spacer(modifier = Modifier.height(contentBottomDp + 200.dp)) - Column( + ChatMessage( + model = model, modifier = Modifier - .offset { - IntOffset( - 0, - if (showAbove) (bounds.top - totalExtraHeightPx).toInt() - .coerceAtLeast(0) else bounds.top.toInt() - ) - } + .offset { IntOffset(0, bounds.top.toInt()) } + .padding(horizontal = 16.dp) + ) + + val menuOffset = if (showAbove) { + bounds.top - totalExtraHeightPx + } else { + bounds.bottom + with(density) { 12.dp.toPx() } // 12dp spacing below + } + + Box( + modifier = Modifier + .offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) } .fillMaxWidth() .padding(horizontal = 16.dp), - horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(12.dp) + contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart ) { - if (showAbove) { - ContextMenu { selectedMessage = null } - ChatMessage(model = model) - } else { - ChatMessage(model = model) - ContextMenu { selectedMessage = null } - } + ContextMenu { selectedMessage = null } } } } -- 2.49.1 From 352863ea7cfcad4f7cf48d419c15f2b129aefd25 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 13 Jul 2026 15:51:39 +0700 Subject: [PATCH 3/4] add menu --- .../composeResources/drawable/ic_copy.xml | 9 ++ .../composeResources/drawable/ic_info.xml | 9 ++ .../composeResources/drawable/ic_reply.xml | 10 +++ .../su/reya/coop/screens/chat/ChatScreen.kt | 88 +++++++++++++------ 4 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_copy.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_info.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_reply.xml diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_copy.xml b/composeApp/src/androidMain/composeResources/drawable/ic_copy.xml new file mode 100644 index 0000000..d57c759 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_info.xml b/composeApp/src/androidMain/composeResources/drawable/ic_info.xml new file mode 100644 index 0000000..bf5dcf6 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_reply.xml b/composeApp/src/androidMain/composeResources/drawable/ic_reply.xml new file mode 100644 index 0000000..d45bfc5 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 3670449..c0baab6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -1,6 +1,7 @@ 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 @@ -38,8 +39,10 @@ import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +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 @@ -55,6 +58,7 @@ 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 @@ -65,6 +69,9 @@ 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 @@ -74,6 +81,9 @@ 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 @@ -100,6 +110,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() @@ -410,11 +421,10 @@ fun ChatScreen( val windowHeight = windowInfo.containerSize.height val scrollState = rememberScrollState() - val menuHeightDp = 336.dp - val spacingDp = 12.dp - val totalExtraHeightPx = with(density) { (menuHeightDp + spacingDp).toPx() } + var menuHeight by remember { mutableFloatStateOf(0f) } + val spacing = with(density) { 12.dp.toPx() } val showAbove = - (windowHeight - bounds.bottom) < totalExtraHeightPx && bounds.top > totalExtraHeightPx + (windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing) Box( modifier = Modifier @@ -423,8 +433,10 @@ fun ChatScreen( .clickable { selectedMessage = null } .verticalScroll(scrollState), ) { - val contentBottomDp = with(density) { (bounds.bottom + totalExtraHeightPx).toDp() } - Spacer(modifier = Modifier.height(contentBottomDp + 200.dp)) + 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, @@ -434,19 +446,33 @@ fun ChatScreen( ) val menuOffset = if (showAbove) { - bounds.top - totalExtraHeightPx + bounds.top - menuHeight - spacing } else { - bounds.bottom + with(density) { 12.dp.toPx() } // 12dp spacing below + 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 { selectedMessage = null } + ContextMenu { action -> + when (action) { + "Copy" -> { + scope.launch { + val content = model.annotatedContent + val data = ClipData.newPlainText(content, content) + clipboardManager.setClipEntry(ClipEntry(data)) + } + } + + else -> {} + } + selectedMessage = null + } } } } @@ -455,29 +481,35 @@ fun ChatScreen( @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), + ) + Surface( - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, 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 + Column(modifier = Modifier.padding(8.dp)) { + menuItems.forEach { (label, icon, hasDivider) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { onAction(label) }, + leadingIcon = { + Icon( + painter = painterResource(icon), + contentDescription = label, + ) + } ) + if (hasDivider) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } } } } -- 2.49.1 From 7d2a81ece3eeb8a80f5ffe7c43d7c77dcfb8954e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 13 Jul 2026 16:27:15 +0700 Subject: [PATCH 4/4] use material 3 expressive theme --- .../su/reya/coop/screens/chat/ChatScreen.kt | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index c0baab6..64e8f0c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -39,6 +39,7 @@ 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 @@ -47,6 +48,7 @@ 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 @@ -479,6 +481,7 @@ fun ChatScreen( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ContextMenu(onAction: (String) -> Unit) { val menuItems = listOf( @@ -487,29 +490,31 @@ private fun ContextMenu(onAction: (String) -> Unit) { Triple("Info", Res.drawable.ic_info, false), ) - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(1, 1), + containerColor = MenuDefaults.groupVibrantContainerColor, modifier = Modifier.width(220.dp) ) { - Column(modifier = Modifier.padding(8.dp)) { - menuItems.forEach { (label, icon, hasDivider) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { onAction(label) }, - leadingIcon = { - Icon( - painter = painterResource(icon), - contentDescription = label, - ) - } - ) - if (hasDivider) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + 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), + ) } } } -- 2.49.1