mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(cordn): close the rest of the gap to Marmot's chat
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: <first 60 characters>", 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
+246
@@ -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<PickedAttachment?>(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<ImageBitmap?>(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"
|
||||
+144
-33
@@ -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<CordnDeliveredMessage?>(null) }
|
||||
var editing by remember { mutableStateOf<CordnDeliveredMessage?>(null) }
|
||||
var attaching by remember { mutableStateOf(false) }
|
||||
var pendingAttachment by remember { mutableStateOf<Uri?>(null) }
|
||||
var attachError by remember { mutableStateOf<String?>(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<Long?>(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<HexKey?>(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<HexKey>,
|
||||
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)))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+195
-11
@@ -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<String, Set<HexKey>>,
|
||||
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<String, Set<HexKey>>,
|
||||
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: <first 60 characters>" 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
|
||||
|
||||
@@ -315,9 +315,7 @@
|
||||
<string name="cordn_action_delete">Delete</string>
|
||||
<string name="cordn_action_pin">Pin</string>
|
||||
<string name="cordn_action_unpin">Unpin</string>
|
||||
<string name="cordn_action_replying">Replying to: %1$s</string>
|
||||
<string name="cordn_action_editing">Editing your message</string>
|
||||
<string name="cordn_action_in_reply_to">Replying to: %1$s</string>
|
||||
<string name="cordn_media_attach">Attach a file</string>
|
||||
<string name="cordn_media_open">Open %1$s</string>
|
||||
<string name="cordn_media_opened">%1$s decrypted. Nothing to show for this file type yet.</string>
|
||||
@@ -368,6 +366,11 @@
|
||||
<string name="cordn_invitations_decline">Decline</string>
|
||||
<string name="cordn_invitations_decline_warning">Declining is permanent. Getting back in means being invited again.</string>
|
||||
<string name="cordn_chat_yesterday">Yesterday</string>
|
||||
<string name="cordn_chat_unread_divider">New messages</string>
|
||||
<string name="cordn_reactions_title">Reactions</string>
|
||||
<string name="cordn_attachment_title">Send file</string>
|
||||
<string name="cordn_attachment_caption_hint">Add a caption</string>
|
||||
<string name="cordn_attachment_uploading">Uploading\u2026</string>
|
||||
<string name="cordn_group_unavailable_detail">Add a coordinator in Settings to start using cordn groups.</string>
|
||||
<string name="cordn_coordinators_discover_explainer">Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked.</string>
|
||||
<string name="cordn_coordinators_discover_action">Look for coordinators</string>
|
||||
@@ -420,6 +423,10 @@
|
||||
<string name="cordn_coordinators_purge_title">Purge this coordinator?</string>
|
||||
<string name="cordn_coordinators_purge_body">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.</string>
|
||||
<string name="cordn_keypackages_explainer">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.</string>
|
||||
<plurals name="cordn_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<plurals name="cordn_keypackages_summary">
|
||||
<item quantity="one">%1$d single-use package available.</item>
|
||||
<item quantity="other">%1$d single-use packages available.</item>
|
||||
|
||||
Reference in New Issue
Block a user