From 44e112fcdc7f597198be82ca5bb8b554fbfccce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:35:04 +0000 Subject: [PATCH] feat(cordn): close the rest of the gap to Marmot's chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six items, all of which the room either lacked outright or showed as a truncated label. **Quoted replies.** A reply used to carry "Replying to: ", which said a reply existed without ever saying who it answered — in a group, most of what you need. `CordnQuotedMessage` names the author in their own pubkey-derived colour behind an accent bar, and is used in two places so what you are answering looks the same while you write it as it does once it is sent. **Jump to the quoted message.** Tapping that quote scrolls to what it answers and flashes it, through ChatBubbleLayout's shouldHighlight — which the bubble already supported and nothing drove. A quote whose target is older than what is loaded flashes nothing rather than scrolling somewhere arbitrary. **Who reacted.** Long-pressing a chip opens the senders per emoji. The fold keys reactions to a set of pubkeys, so the sheet is the whole truth the room holds — there is no separate receipt and no count that could disagree with the list under it. **Unread divider.** From a cursor snapshotted when the room opens: markRead() runs on open, so a divider read from the live cursor would erase itself at the moment it became useful. Own traffic is excluded, to match what unreadCount already counts — a message of yours echoing back is not news. It draws below the day separator, since in a reversed list the later-emitted row draws higher. **Faces and a member count in the top bar**, with the whole title as the way in to group info, as in every other group chat. **A confirm step before sending a file.** Attaching was irreversible the instant the picker closed: no preview, no caption, no way back from the wrong file — and a cordn attachment is encrypted, uploaded and announced to the room in one action that cannot be recalled. The dialog previews images (bounds-first subsampled decode, on IO), takes a caption that rides as the message's own content so an attachment with something written about it is one message rather than two, and holds itself open on failure so retrying is one tap rather than the picker again. Its progress bar is indeterminate on purpose: the upload is a single suspend call that encrypts and posts and reports nothing in between, so a percentage would be invented. A stage string written for a two-phase label was dropped rather than wired to a phase that isn't observable. It also fixes the filename. `uri.lastPathSegment` is a document id on a content:// URI, not a name — it is what every cordn attachment has been named. The dialog resolves the real one through OpenableColumns, off the main thread, and hands it to the upload. Left alone deliberately: zaps and the shared note-action inventory need a Note, which a cordn room may not have. Un-reacting still needs the reaction's own envelope id, which the annotation index does not keep — protocol work, not UI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../chats/cordnGroup/CordnAttachmentDialog.kt | 246 ++++++++++++++++++ .../chats/cordnGroup/CordnGroupChatScreen.kt | 177 ++++++++++--- .../chats/cordnGroup/CordnMessageRow.kt | 206 ++++++++++++++- amethyst/src/main/res/values/strings.xml | 11 +- 4 files changed, 594 insertions(+), 46 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt new file mode 100644 index 0000000000..82ca185b35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import android.provider.OpenableColumns +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** What the picker handed over, resolved enough to show it before it is sent. */ +internal data class PickedAttachment( + val uri: Uri, + val displayName: String, + val mimeType: String, +) + +/** + * The confirmation step between picking a file and sending it. + * + * Attaching used to be irreversible the instant the picker closed: no preview, no + * caption, and no way back if you picked the wrong file — which matters more here than + * in a public chat, because a cordn attachment is encrypted, uploaded and announced to + * the room in one action that cannot be recalled. + * + * The caption rides as the message's own content, so an attachment with a caption is + * one message rather than two. + */ +@Composable +internal fun CordnAttachmentDialog( + uri: Uri, + sending: Boolean, + onDismiss: () -> Unit, + onSend: (picked: PickedAttachment, caption: String) -> Unit, +) { + val context = LocalContext.current + var caption by remember(uri) { mutableStateOf("") } + + // Resolving the name is a binder call into a DocumentsProvider, which is not + // something to do on the frame that opens the dialog. + val picked by + produceState(null, uri) { + value = withContext(Dispatchers.IO) { resolvePickedAttachment(context, uri) } + } + val resolved = picked + + AlertDialog( + // A dismiss mid-upload would leave the dialog's own progress unobservable + // while the work carried on, so it only closes when it is idle. + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text(stringRes(R.string.cordn_attachment_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (resolved != null) AttachmentPreview(resolved) + + OutlinedTextField( + value = caption, + onValueChange = { caption = it }, + enabled = !sending, + placeholder = { Text(stringRes(R.string.cordn_attachment_caption_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + + if (sending) { + // Indeterminate on purpose: the upload is one suspend call that + // encrypts and posts, and it reports nothing in between. A bar + // that invented a percentage would be a lie about what is known. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringRes(R.string.cordn_attachment_uploading), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { resolved?.let { onSend(it, caption) } }, + enabled = !sending && resolved != null, + ) { + Text(stringRes(R.string.cordn_send)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !sending) { + Text(stringRes(Res.string.cancel)) + } + }, + ) +} + +/** A thumbnail for an image, the name and type for everything else. */ +@Composable +private fun AttachmentPreview(picked: PickedAttachment) { + val context = LocalContext.current + + val thumbnail by + produceState(null, picked.uri) { + value = + if (picked.mimeType.startsWith("image/")) { + withContext(Dispatchers.IO) { decodeThumbnail(context, picked.uri) } + } else { + null + } + } + + val shown = thumbnail + if (shown != null) { + Image( + bitmap = shown, + contentDescription = picked.displayName, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp), + ) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = picked.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = picked.mimeType, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +/** Longest edge of the preview bitmap. A dialog never needs the full image. */ +private const val THUMBNAIL_MAX_EDGE = 1024 + +/** + * Decodes [uri] small enough to preview. + * + * Bounds first, then a subsampled decode: a modern phone photo decoded at full size is + * tens of megabytes, and this runs to draw something a few hundred pixels tall. + */ +private fun decodeThumbnail( + context: Context, + uri: Uri, +): ImageBitmap? = + runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + + var sample = 1 + while (bounds.outWidth / sample > THUMBNAIL_MAX_EDGE || bounds.outHeight / sample > THUMBNAIL_MAX_EDGE) { + sample *= 2 + } + + val options = BitmapFactory.Options().apply { inSampleSize = sample } + context.contentResolver + .openInputStream(uri) + ?.use { BitmapFactory.decodeStream(it, null, options) } + ?.asImageBitmap() + }.getOrNull() + +/** + * The file's real name and type. + * + * `uri.lastPathSegment` is a document id on a `content://` URI, not a name — it is what + * the room used to show for every attachment, and what the dialog would otherwise put + * in front of the person confirming the send. + */ +internal fun resolvePickedAttachment( + context: Context, + uri: Uri, +): PickedAttachment { + val resolver = context.contentResolver + val mime = resolver.getType(uri) ?: OPAQUE_MIME + + val name = + runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null + } + }.getOrNull() + ?: uri.lastPathSegment?.substringAfterLast('/') + ?: FALLBACK_NAME + + return PickedAttachment(uri, name, mime) +} + +private const val OPAQUE_MIME = "application/octet-stream" +private const val FALLBACK_NAME = "file" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index 2b21f0c1aa..ccac9562e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -31,6 +31,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -87,6 +88,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload @@ -150,6 +153,7 @@ private fun CordnGroupChat( val messages by room.messages.collectAsStateWithLifecycle() val annotations by room.annotations.collectAsStateWithLifecycle() val name by room.name.collectAsStateWithLifecycle() + val members by room.members.collectAsStateWithLifecycle() val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -159,6 +163,7 @@ private fun CordnGroupChat( var replyingTo by remember { mutableStateOf(null) } var editing by remember { mutableStateOf(null) } var attaching by remember { mutableStateOf(false) } + var pendingAttachment by remember { mutableStateOf(null) } var attachError by remember { mutableStateOf(null) } // Why sending says anything at all when it fails: `manager()` is null-safe @@ -223,11 +228,22 @@ private fun CordnGroupChat( } } + // Where the divider goes, taken once per visit. markRead() below moves the live + // cursor to the newest message, so reading it per frame would erase the line at + // exactly the moment it starts being useful. Restored state first, because the + // cursor this reads is the one that was persisted. + var unreadFrom by remember(room.gid) { mutableStateOf(null) } + LaunchedEffect(room) { runtime?.restoreRoomState(room.coordinatorPubKey, room.gid) + unreadFrom = room.lastReadCursor.value.takeIf { room.unreadCount.value > 0 } room.markRead() } + // Tapping a reply's quote jumps to the message it answers and flashes it; the + // bubble clears this itself once the flash is done. + var highlighted by remember(room.gid) { mutableStateOf(null) } + // The same scaffold every other chat screen uses. A bare Scaffold gave this room // neither of the two things it provides: the bars' scroll behaviour, and the IME // inset — without which the composer sat *under* the soft keyboard. @@ -236,6 +252,8 @@ private fun CordnGroupChat( topBar = { CordnChatTopBar( title = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)), + members = members, + accountViewModel = accountViewModel, onBack = { nav.popBack() }, onInfo = { nav.nav(Route.CordnGroupInfo(room.coordinatorPubKey, room.gid)) }, ) @@ -247,6 +265,32 @@ private fun CordnGroupChat( // One clock for every divider in the room, so they cannot disagree. val today = rememberToday() + // Reversed once, into a val: `asReversed()` is a view, and indexing it + // per item to find the neighbour is how a list like this quietly becomes + // quadratic. Hoisted out of the LazyColumn because the jump below needs to + // find a message's row by id. + val rows = remember(messages) { messages.asReversed() } + + // The oldest message this visit had not seen. Own traffic is excluded for + // the same reason unreadCount excludes it: a message of yours coming back + // as an echo is not news, and a divider above it would say it was. + val firstUnreadId = + remember(rows, unreadFrom, me) { + unreadFrom?.let { readUpTo -> + rows.lastOrNull { it.cursor > readUpTo && it.envelope.pubKey != me }?.envelope?.id + } + } + + val jumpTo: (HexKey) -> Unit = { id -> + val index = rows.indexOfFirst { it.envelope.id == id } + // Not found means the quoted message is older than what is loaded. + // Flashing nothing is better than scrolling somewhere arbitrary. + if (index >= 0) { + highlighted = id + scope.launch { listState.animateScrollToItem(index) } + } + } + // Pinned messages sit above the conversation rather than inside it. // A pin is a claim about a message's importance, not a message, and // leaving it only in place means the thing someone pinned scrolls @@ -271,11 +315,6 @@ private fun CordnGroupChat( reverseLayout = true, modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp), ) { - // Reversed once, into a val: `asReversed()` is a view, and - // indexing it per item to find the neighbour is how a list - // like this quietly becomes quadratic. - val rows = messages.asReversed() - itemsIndexed(rows, key = { _, it -> it.envelope.id }) { index, message -> // `rows` runs newest-first and the list is reverse-laid-out, // so the next index is the older message and renders above. @@ -301,8 +340,11 @@ private fun CordnGroupChat( // a deleted message would defeat the deletion. text = if (annotations.isDeleted(message.envelope.id)) null else annotations.contentOf(message.envelope.id), isEdited = annotations.isEdited(message.envelope.id), + shouldHighlight = highlighted == message.envelope.id, accountViewModel = accountViewModel, nav = nav, + onHighlightFinished = { highlighted = null }, + onScrollToMessage = jumpTo, onReply = { replyingTo = message editing = null @@ -337,6 +379,13 @@ private fun CordnGroupChat( }, ) + // Both are emitted after the row, which in a reversed list puts + // them above it — so the later one draws higher. The unread line + // belongs against the message, the date above the whole day. + if (message.envelope.id == firstUnreadId) { + UnreadDivider() + } + // Drawn under the first message of each day, which in a // reversed list means comparing against the older row. if (!message.sameDayAs(older)) { @@ -356,12 +405,54 @@ private fun CordnGroupChat( ) } + pendingAttachment?.let { pending -> + CordnAttachmentDialog( + uri = pending, + sending = attaching, + onDismiss = { pendingAttachment = null }, + onSend = { resolved, caption -> + scope.launch { + attaching = true + attachError = null + try { + sendAttachment(context, accountViewModel, room, resolved, caption) + // Only on success: a failed send leaves the dialog up + // with what you picked still in it, so retrying is one + // tap rather than the picker again. + pendingAttachment = null + } catch (e: Exception) { + // A failed attachment left no trace anywhere; the + // banner tells the person, this tells whoever has + // to work out why. + Log.w("CordnGroupChat", "attachment failed in ${room.gid}: ${e.message}", e) + attachError = e.message ?: uploadFailed + } finally { + attaching = false + } + } + }, + ) + } + val replyPreview = replyingTo if (replyPreview != null) { - ComposerBanner( - label = stringRes(R.string.cordn_action_replying, annotations.contentOf(replyPreview.envelope.id).orEmpty().take(60)), - onCancel = { replyingTo = null }, - ) + // The same quote block the bubbles use, so what you are answering looks + // the same while you write it as it does once it is sent. + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + CordnQuotedMessage( + parent = replyPreview, + annotations = annotations, + accountViewModel = accountViewModel, + ) + } + IconButton(onClick = { replyingTo = null }) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.cancel)) + } + } } if (editing != null) { ComposerBanner( @@ -376,23 +467,11 @@ private fun CordnGroupChat( CordnComposer( room = room, accountViewModel = accountViewModel, - onAttach = { uri -> - scope.launch { - attaching = true - attachError = null - try { - sendAttachment(context, accountViewModel, room, uri) - } catch (e: Exception) { - // A failed attachment left no trace anywhere; the - // banner tells the person, this tells whoever has - // to work out why. - Log.w("CordnGroupChat", "attachment failed in ${room.gid}: ${e.message}", e) - attachError = e.message ?: uploadFailed - } finally { - attaching = false - } - } - }, + // Picking a file no longer sends it. A cordn attachment is encrypted, + // uploaded and announced to the room in one irreversible action, so it + // gets the same confirm-first treatment as anything else that cannot be + // taken back. + onAttach = { uri -> pendingAttachment = uri }, onVoiceNote = { recording -> scope.launch { attaching = true @@ -518,11 +597,38 @@ private fun ComposerBanner( @Composable private fun CordnChatTopBar( title: String, + members: List, + accountViewModel: AccountViewModel, onBack: () -> Unit, onInfo: () -> Unit, ) { TopAppBar( - title = { Text(title) }, + title = { + // The whole title is the way in to group info, as it is in every other + // group chat — the faces say who is in the room before you open it. + Row( + modifier = Modifier.clickable(onClick = onInfo), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (members.isNotEmpty()) { + NonClickableUserPictures( + userHexList = members, + size = 36.dp, + accountViewModel = accountViewModel, + ) + } + Column { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (members.isNotEmpty()) { + Text( + text = pluralStringRes(LocalContext.current, R.plurals.cordn_member_count, members.size, members.size), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -600,7 +706,8 @@ private suspend fun sendAttachment( context: Context, accountViewModel: AccountViewModel, room: CordnGroupChatroom, - uri: Uri, + picked: PickedAttachment, + caption: String, ) { // Every step here used to `?: return`, which reads as "nothing to do" and // behaves as "the attach button does nothing at all": the picker closed, @@ -614,11 +721,12 @@ private suspend fun sendAttachment( session.manager.group(room.gid) ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session)) - val resolver = context.contentResolver - val mime = resolver.getType(uri) ?: CordnBlobUpload.OPAQUE - val name = uri.lastPathSegment?.substringAfterLast('/') ?: "file" + // Name and type come from the dialog, which resolved them through OpenableColumns + // — `uri.lastPathSegment` is a document id on a content:// URI, not a filename. + val mime = picked.mimeType.ifBlank { CordnBlobUpload.OPAQUE } + val name = picked.displayName val bytes = - withContext(Dispatchers.IO) { resolver.openInputStream(uri)?.use { it.readBytes() } } + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(picked.uri)?.use { it.readBytes() } } ?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_unreadable)) // Null means the account has no Blossom server, which is a setting the @@ -630,7 +738,10 @@ private suspend fun sendAttachment( // Into the room as well, for the same reason every other send is: an // attachment of your own echoes back as an Echo and would otherwise be // invisible to the person who sent it. - room.add(session.manager.send(room.gid, content = "", tags = arrayOf(tag))) + // + // The caption is the message's own content, so an attachment with something + // written about it is one message rather than two. + room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag))) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt index fd886eb648..6b419bc377 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -21,15 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -39,10 +45,13 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -120,8 +129,11 @@ internal fun CordnMessageRow( groupPosition: ChatGroupPosition, text: String?, isEdited: Boolean, + shouldHighlight: Boolean, accountViewModel: AccountViewModel, nav: INav, + onHighlightFinished: () -> Unit, + onScrollToMessage: (HexKey) -> Unit, onReply: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, @@ -155,6 +167,8 @@ internal fun CordnMessageRow( drawAuthorInfo = groupPosition.isFirstOfGroup && !isMine, groupPosition = groupPosition, transparentBubble = jumboCount > 0, + shouldHighlight = shouldHighlight, + onHighlightFinished = onHighlightFinished, // A plain tap is a no-op, exactly as in every other Amethyst chat. onClick = { false }, onDoubleTap = @@ -188,7 +202,7 @@ internal fun CordnMessageRow( if (reactions.isEmpty()) { null } else { - { CordnReactionChips(reactions, me, onReact) } + { CordnReactionChips(reactions, me, accountViewModel, nav, onReact) } }, // Mirrors chatFooterHasMeta: the footer earns its row on the last message // of a burst (for the time) or on any message carrying a marker of its own. @@ -215,6 +229,7 @@ internal fun CordnMessageRow( jumboCount = jumboCount, accountViewModel = accountViewModel, nav = nav, + onScrollToMessage = onScrollToMessage, ) } } @@ -235,16 +250,16 @@ private fun CordnBubbleContents( jumboCount: Int, accountViewModel: AccountViewModel, nav: INav, + onScrollToMessage: (HexKey) -> Unit, ) { val thread = remember(message.envelope.id) { CordnMessageReferences.thread(message.envelope.tags) } val parent = thread?.let { annotations.byId[it.parentId] } if (parent != null) { - Text( - text = stringRes(R.string.cordn_action_in_reply_to, annotations.contentOf(parent.envelope.id).orEmpty().take(60)), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + CordnQuotedMessage( + parent = parent, + annotations = annotations, + accountViewModel = accountViewModel, + onClick = { onScrollToMessage(parent.envelope.id) }, ) } @@ -323,6 +338,8 @@ private fun CordnAuthorLine( private fun CordnReactionChips( reactions: Map>, me: HexKey, + accountViewModel: AccountViewModel, + nav: INav, onReact: (String) -> Unit, ) { val chips = @@ -332,20 +349,187 @@ private fun CordnReactionChips( .sortedByDescending { it.count } } + var showWho by remember { mutableStateOf(false) } + + if (showWho) { + CordnReactionDetailSheet( + reactions = reactions, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showWho = false }, + ) + } + ChatChipFlowRow { chips.forEach { chip -> ReactionChipView( chip = chip, onClick = { onReact(chip.type) }, - // The DM strip opens "who reacted" on a long press. cordn has no such - // sheet yet, and a long press that re-sent the reaction would be worse - // than one that does nothing. - onLongClick = {}, + // Long press opens who reacted, as the DM strip does. + onLongClick = { showWho = true }, ) } } } +/** + * Who reacted, and with what. + * + * The fold keys reactions by emoji to a *set* of senders, so this is the whole truth + * the room holds about them — there is no separate receipt to open, and no count that + * could disagree with the list under it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnReactionDetailSheet( + reactions: Map>, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + // Most-reacted first, matching the order of the chips that opened this. + val groups = remember(reactions) { reactions.entries.sortedByDescending { it.value.size } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + Text( + text = stringRes(R.string.cordn_reactions_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + + groups.forEach { (emoji, who) -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = emoji, style = MaterialTheme.typography.titleMedium) + Text( + text = who.size.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + who.forEach { pubKey -> + Row( + Modifier.fillMaxWidth().padding(start = 36.dp, end = 20.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture( + userHex = pubKey, + size = Size20dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = observeUserNameByHex(pubKey, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** + * The message a reply is answering, drawn above it. + * + * A one-line "Replying to: " told you a reply existed without ever + * saying who it answered, which in a group is most of what you need. This names the + * author in their own colour and is tappable, so a reply is a way back to what it + * answers rather than a dead label. + */ +@Composable +internal fun CordnQuotedMessage( + parent: CordnDeliveredMessage, + annotations: CordnAnnotationIndex, + accountViewModel: AccountViewModel, + onClick: (() -> Unit)? = null, +) { + val isLightTheme = MaterialTheme.colorScheme.isLight + val authorColor = + remember(parent.envelope.pubKey, isLightTheme) { + authorNameColorFor(parent.envelope.pubKey, isLightTheme) + } + + val body = + if (annotations.isDeleted(parent.envelope.id)) { + stringRes(R.string.cordn_message_deleted) + } else { + annotations.contentOf(parent.envelope.id).orEmpty() + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(QuoteShape) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = QUOTE_TINT_ALPHA)) + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // The accent bar reads as "this is quoted" at a glance, and takes the author's + // own colour so a run of replies to different people stays distinguishable. + Box(Modifier.width(3.dp).fillMaxHeight().background(authorColor)) + + Column(Modifier.padding(top = 6.dp, bottom = 6.dp, end = 8.dp)) { + Text( + text = observeUserNameByHex(parent.envelope.pubKey, accountViewModel), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = authorColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private val QuoteShape = RoundedCornerShape(6.dp) + +/** How strongly a quoted message is lifted off the bubble it sits in. */ +private const val QUOTE_TINT_ALPHA = 0.06f + +/** + * The line where the messages you have already read end. + * + * Drawn from a cursor snapshotted when the room opened: `markRead()` runs on open, so a + * divider read from the live cursor would vanish the moment it became useful. + */ +@Composable +internal fun UnreadDivider() { + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider(Modifier.weight(1f), color = MaterialTheme.colorScheme.primary) + Text( + text = stringRes(R.string.cordn_chat_unread_divider), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(Modifier.weight(1f), color = MaterialTheme.colorScheme.primary) + } +} + /** * The bubble's bottom-corner footer: the markers this message carries, then the time on * the last bubble of a burst. Until this existed a cordn room showed no per-message diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c12666c8ad..e1a0d78d39 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -315,9 +315,7 @@ Delete Pin Unpin - Replying to: %1$s Editing your message - Replying to: %1$s Attach a file Open %1$s %1$s decrypted. Nothing to show for this file type yet. @@ -368,6 +366,11 @@ Decline Declining is permanent. Getting back in means being invited again. Yesterday + New messages + Reactions + Send file + Add a caption + Uploading\u2026 Add a coordinator in Settings to start using cordn groups. Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked. Look for coordinators @@ -420,6 +423,10 @@ Purge this coordinator? Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had. Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why. + + %1$d member + %1$d members + %1$d single-use package available. %1$d single-use packages available.