From 0adcc97f0f62a518f6e98fdf83d804fa7dabf0df Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 15:17:43 +0300 Subject: [PATCH] android: split tap context menu and long-press multi-select in chat --- android/CHANGELOG.md | 7 ++ .../messenger/ui/chat/ChatScreen.kt | 80 +++++++++++++------ 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index c0b47a5..2b1868d 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -323,3 +323,10 @@ - Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore). - Switched DI token binding from DataStore token repository to encrypted shared preferences repository. - Kept DataStore for non-token app settings and renamed preferences file to `messenger_preferences.preferences_pb`. + +### Step 54 - Message interactions: tap menu vs long-press select +- Updated chat message gesture behavior to match Telegram pattern: + - tap opens contextual message menu with reactions/actions, + - long-press enters multi-select mode directly. +- Hid single-selection action bars while contextual menu is visible to avoid mixed UX states. +- Improved multi-select visual affordance with per-message selection indicator circles. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index ba0460b..cf1c5d9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -248,23 +248,26 @@ fun ChatScreen( MessageBubble( message = message, isSelected = isSelected, + isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, reactions = state.reactionByMessageId[message.id].orEmpty(), onAttachmentImageClick = { imageUrl -> val idx = allImageUrls.indexOf(imageUrl) viewerImageIndex = if (idx >= 0) idx else null }, onClick = { - actionMenuMessage = null if (state.actionState.mode == MessageSelectionMode.MULTI) { onToggleMessageMultiSelection(message) + } else { + onSelectMessage(message) + actionMenuMessage = message } }, onLongPress = { if (state.actionState.mode == MessageSelectionMode.MULTI) { onToggleMessageMultiSelection(message) } else { - onSelectMessage(message) - actionMenuMessage = message + actionMenuMessage = null + onEnterMultiSelect(message) } }, ) @@ -336,7 +339,7 @@ fun ChatScreen( Button(onClick = {}, enabled = false) { Text("Pin") } - Button(onClick = { actionMenuMessage = null }) { + Button(onClick = { actionMenuMessage = null; onClearSelection() }) { Text("Close") } } @@ -344,7 +347,9 @@ fun ChatScreen( } } - if (state.actionState.hasSelection) { + if (state.actionState.hasSelection && + !(state.actionState.mode == MessageSelectionMode.SINGLE && actionMenuMessage != null) + ) { Row( modifier = Modifier .fillMaxWidth() @@ -359,12 +364,15 @@ fun ChatScreen( style = MaterialTheme.typography.labelLarge, modifier = Modifier.weight(1f), ) - Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } - Button( - onClick = { onDeleteSelected(true) }, - enabled = state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedCanDeleteForAll, - ) { Text("Del for all") } - if (state.actionState.mode == MessageSelectionMode.SINGLE) { + if (state.actionState.mode == MessageSelectionMode.MULTI) { + Button(onClick = onForwardSelected) { Text("Forward") } + Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } + } else { + Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } + Button( + onClick = { onDeleteSelected(true) }, + enabled = state.selectedCanDeleteForAll, + ) { Text("Del for all") } Button( onClick = { state.selectedMessage?.let(onEditSelected) }, enabled = state.selectedCanEdit, @@ -383,7 +391,10 @@ fun ChatScreen( if (state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedMessage != null) { Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } } - Button(onClick = onForwardSelected) { + Button( + onClick = onForwardSelected, + enabled = state.actionState.mode == MessageSelectionMode.MULTI, + ) { Text(if (state.actionState.mode == MessageSelectionMode.MULTI) "Forward selected" else "Forward") } } @@ -610,6 +621,7 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? { private fun MessageBubble( message: MessageItem, isSelected: Boolean, + isMultiSelecting: Boolean, reactions: List, onAttachmentImageClick: (String) -> Unit, onClick: () -> Unit, @@ -628,19 +640,31 @@ private fun MessageBubble( } val alignment = if (isOutgoing) Alignment.End else Alignment.Start Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) { - Column( - modifier = Modifier - .fillMaxWidth(0.86f) - .background( - color = if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor, - shape = bubbleShape, - ) - .combinedClickable( - onClick = onClick, - onLongClick = onLongPress, - ) - .padding(horizontal = 10.dp, vertical = 7.dp), + Row( + modifier = Modifier.fillMaxWidth(0.92f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, ) { + if (isMultiSelecting && !isOutgoing) { + Text( + text = if (isSelected) "◉" else "○", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 6.dp), + ) + } + Column( + modifier = Modifier + .fillMaxWidth(0.92f) + .background( + color = if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor, + shape = bubbleShape, + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongPress, + ) + .padding(horizontal = 10.dp, vertical = 7.dp), + ) { if (isSelected) { Text( text = "✓ Selected", @@ -764,6 +788,14 @@ private fun MessageBubble( style = MaterialTheme.typography.labelSmall, ) } + } + if (isMultiSelecting && isOutgoing) { + Text( + text = if (isSelected) "◉" else "○", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 6.dp), + ) + } } } }