android: split tap context menu and long-press multi-select in chat
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 15:17:43 +03:00
parent c80ff650b2
commit 0adcc97f0f
2 changed files with 63 additions and 24 deletions

View File

@@ -323,3 +323,10 @@
- Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore). - Added `EncryptedPrefsTokenRepository` backed by `EncryptedSharedPreferences` and Android `MasterKey` (Keystore).
- Switched DI token binding from DataStore token repository to encrypted shared preferences repository. - 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`. - 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.

View File

@@ -248,23 +248,26 @@ fun ChatScreen(
MessageBubble( MessageBubble(
message = message, message = message,
isSelected = isSelected, isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
reactions = state.reactionByMessageId[message.id].orEmpty(), reactions = state.reactionByMessageId[message.id].orEmpty(),
onAttachmentImageClick = { imageUrl -> onAttachmentImageClick = { imageUrl ->
val idx = allImageUrls.indexOf(imageUrl) val idx = allImageUrls.indexOf(imageUrl)
viewerImageIndex = if (idx >= 0) idx else null viewerImageIndex = if (idx >= 0) idx else null
}, },
onClick = { onClick = {
actionMenuMessage = null
if (state.actionState.mode == MessageSelectionMode.MULTI) { if (state.actionState.mode == MessageSelectionMode.MULTI) {
onToggleMessageMultiSelection(message) onToggleMessageMultiSelection(message)
} else {
onSelectMessage(message)
actionMenuMessage = message
} }
}, },
onLongPress = { onLongPress = {
if (state.actionState.mode == MessageSelectionMode.MULTI) { if (state.actionState.mode == MessageSelectionMode.MULTI) {
onToggleMessageMultiSelection(message) onToggleMessageMultiSelection(message)
} else { } else {
onSelectMessage(message) actionMenuMessage = null
actionMenuMessage = message onEnterMultiSelect(message)
} }
}, },
) )
@@ -336,7 +339,7 @@ fun ChatScreen(
Button(onClick = {}, enabled = false) { Button(onClick = {}, enabled = false) {
Text("Pin") Text("Pin")
} }
Button(onClick = { actionMenuMessage = null }) { Button(onClick = { actionMenuMessage = null; onClearSelection() }) {
Text("Close") Text("Close")
} }
} }
@@ -344,7 +347,9 @@ fun ChatScreen(
} }
} }
if (state.actionState.hasSelection) { if (state.actionState.hasSelection &&
!(state.actionState.mode == MessageSelectionMode.SINGLE && actionMenuMessage != null)
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -359,12 +364,15 @@ fun ChatScreen(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Button(onClick = { onDeleteSelected(false) }) { Text("Delete") } if (state.actionState.mode == MessageSelectionMode.MULTI) {
Button( Button(onClick = onForwardSelected) { Text("Forward") }
onClick = { onDeleteSelected(true) }, Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
enabled = state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedCanDeleteForAll, } else {
) { Text("Del for all") } Button(onClick = { onDeleteSelected(false) }) { Text("Delete") }
if (state.actionState.mode == MessageSelectionMode.SINGLE) { Button(
onClick = { onDeleteSelected(true) },
enabled = state.selectedCanDeleteForAll,
) { Text("Del for all") }
Button( Button(
onClick = { state.selectedMessage?.let(onEditSelected) }, onClick = { state.selectedMessage?.let(onEditSelected) },
enabled = state.selectedCanEdit, enabled = state.selectedCanEdit,
@@ -383,7 +391,10 @@ fun ChatScreen(
if (state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedMessage != null) { if (state.actionState.mode == MessageSelectionMode.SINGLE && state.selectedMessage != null) {
Button(onClick = { onReplySelected(state.selectedMessage) }) { Text("Reply") } 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") 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( private fun MessageBubble(
message: MessageItem, message: MessageItem,
isSelected: Boolean, isSelected: Boolean,
isMultiSelecting: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>, reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit, onAttachmentImageClick: (String) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
@@ -628,19 +640,31 @@ private fun MessageBubble(
} }
val alignment = if (isOutgoing) Alignment.End else Alignment.Start val alignment = if (isOutgoing) Alignment.End else Alignment.Start
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) { Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = alignment) {
Column( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(0.92f),
.fillMaxWidth(0.86f) verticalAlignment = Alignment.CenterVertically,
.background( horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
color = if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor,
shape = bubbleShape,
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongPress,
)
.padding(horizontal = 10.dp, vertical = 7.dp),
) { ) {
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) { if (isSelected) {
Text( Text(
text = "✓ Selected", text = "✓ Selected",
@@ -764,6 +788,14 @@ private fun MessageBubble(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
} }
}
if (isMultiSelecting && isOutgoing) {
Text(
text = if (isSelected) "" else "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 6.dp),
)
}
} }
} }
} }