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.