refactor(cordn): stop duplicating three shared chat components

An audit of what the cordn chat still defines for itself turned up three
components it was reimplementing rather than reusing. One of them I had
written two commits earlier, which is the more embarrassing half.

**The upload dialog.** ChatFileUploadDialog has zero Note/LocalCache
references and is used by the DMs, Marmot, Concord, minichat, the public
channels and Nests — every upload surface except cordn, which had
CordnAttachmentDialog instead. Mine was also worse: a caption and
nothing else, against the shared one's caption, media-quality slider,
metadata stripping and server picker. Marmot already proved the pattern
works for a surface with its own encrypted pipeline — it passes
`upload = { MarmotFileUploader(...).uploadMip04(viewState = state, …) }`
— and cordn now does the same with CordnMediaService behind it. The
duplicate is deleted.

cordn cannot go through UploadOrchestrator like the nostr surfaces: its
`imeta` tag carries the hash of the *plaintext* and a per-file key, which
the orchestrator neither produces nor surfaces. So the dialog's two
byte-level choices are applied in sendAttachment against the same
helpers the orchestrator uses — compressIfNeeded for the quality slider,
MetadataStripper for the strip switch — and the encryption and upload
stay where they were.

The sensitive-content switch is the one control with nowhere to go:
cordn tells its blob host a fixed set of constants on purpose, and its
`imeta` tag has no field for a warning. Rather than leave a switch that
does nothing, the dialog takes `showContentWarning`, defaulting true, in
the same shape as the `isNip17` gate already beside it.

CordnMediaService.upload gains a server parameter, defaulted to the
account's, so the dialog's spinner actually selects a host.

**ChatDivisor.** The day separator and the unread line were hand-rolled
Row + HorizontalDivider + Text at a different type scale from every
other chat's date divisor — the exact mismatch this branch set out to
remove, reintroduced. Both now call the shared one, which takes an
optional tint (Unspecified = today's behaviour) so the unread line can
carry the accent without a second component.

**VoiceMessagePreview.** Recording sent the moment you released the
button: no playback, no re-record, nothing to undo — hard to get right
first time and impossible to take back. The shared preview the post and
voice-reply screens use now sits above the field, and the text beside it
rides along as the note's caption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 14:05:49 +00:00
parent 44e112fcdc
commit 50184ed6fe
8 changed files with 208 additions and 333 deletions
@@ -82,11 +82,12 @@ class CordnMediaService(
mimeType: String,
filename: String,
context: Context,
/** The host to put it on. Defaults to the account's, which is what the
* upload dialog's server spinner starts on. */
serverBaseUrl: String = account.settings.defaultFileServer.baseUrl,
): Array<String>? =
withContext(Dispatchers.IO) {
val server =
account.settings.defaultFileServer.baseUrl
.ifBlank { return@withContext null }
val server = serverBaseUrl.ifBlank { return@withContext null }
// A fresh key per file, carried in the descriptor. Deriving it from
// the group would tie the attachment to the epoch it was sent in
@@ -1,246 +0,0 @@
/*
* 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"
@@ -56,11 +56,13 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
/**
* The cordn room's composer, on the same field every other Amethyst chat uses.
@@ -84,9 +86,11 @@ import com.vitorpamplona.amethyst.ui.stringRes
internal fun CordnComposer(
room: CordnGroupChatroom,
attaching: Boolean,
pendingVoice: RecordingResult?,
accountViewModel: AccountViewModel,
onAttach: (Uri) -> Unit,
onVoiceNote: (RecordingResult) -> Unit,
onRemoveVoice: () -> Unit,
onSend: (String) -> Unit,
) {
// `room.draft` is the persisted String — commons holds the draft and may not depend
@@ -127,7 +131,9 @@ internal fun CordnComposer(
onDispose { suggestions.reset() }
}
val canPost by remember { derivedStateOf { draftState.text.isNotBlank() } }
// A recorded voice note is something to send even with nothing typed; the text
// beside it rides along as its caption.
val canPost by remember(pendingVoice) { derivedStateOf { draftState.text.isNotBlank() || pendingVoice != null } }
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
@@ -135,6 +141,32 @@ internal fun CordnComposer(
}
Column(modifier = EditFieldModifier) {
// The same preview the post screens use: listen back, re-record, or drop it.
// Recording used to send the moment you released the button, which is a hard
// thing to get right first time and impossible to take back afterwards.
pendingVoice?.let { recording ->
val meta =
remember(recording) {
AudioMeta(
// Empty: nothing is uploaded yet, so the preview plays the
// local file instead.
url = "",
mimeType = recording.mimeType,
duration = recording.duration,
waveform = recording.amplitudes,
)
}
VoiceMessagePreview(
voiceMetadata = meta,
localFile = recording.file,
onRemove = onRemoveVoice,
onReRecord = onVoiceNote,
isUploading = attaching,
modifier = Modifier.fillMaxWidth(),
)
}
ShowUserSuggestionList(
suggestions,
onSelect = { user ->
@@ -26,6 +26,7 @@ import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaPlayer
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@@ -85,12 +86,17 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
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.screen.loggedIn.chats.utils.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment
@@ -99,6 +105,7 @@ import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -163,7 +170,16 @@ 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) }
// The same upload state every other chat's dialog is built on — caption, media
// quality, metadata stripping and the server to put it on.
val uploadState =
remember(room.gid) {
ChatFileUploadState(
accountViewModel.account.settings.defaultFileServer,
accountViewModel.account.settings.stripLocationOnUpload,
)
}
var pendingVoice by remember { mutableStateOf<RecordingResult?>(null) }
var attachError by remember { mutableStateOf<String?>(null) }
// Why sending says anything at all when it fails: `manager()` is null-safe
@@ -405,21 +421,23 @@ private fun CordnGroupChat(
)
}
pendingAttachment?.let { pending ->
CordnAttachmentDialog(
uri = pending,
sending = attaching,
onDismiss = { pendingAttachment = null },
onSend = { resolved, caption ->
// The app's upload dialog, as Marmot, Concord, the DMs, minichat and Nests
// all use it. cordn keeps its own uploader behind it — encrypted blobs to
// Blossom, not a nostr media post — which is exactly how Marmot uses it too.
if (uploadState.multiOrchestrator != null) {
ChatFileUploadDialog(
state = uploadState,
title = { Text(name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8))) },
upload = {
scope.launch {
attaching = true
attachError = null
try {
sendAttachment(context, accountViewModel, room, resolved, caption)
// Only on success: a failed send leaves the dialog up
sendAttachment(context, accountViewModel, room, uploadState)
// Only on success: a failed upload leaves the dialog up
// with what you picked still in it, so retrying is one
// tap rather than the picker again.
pendingAttachment = null
uploadState.reset()
} catch (e: Exception) {
// A failed attachment left no trace anywhere; the
// banner tells the person, this tells whoever has
@@ -431,6 +449,13 @@ private fun CordnGroupChat(
}
}
},
onCancel = uploadState::reset,
accountViewModel = accountViewModel,
nav = nav,
// cordn tells its blob host a fixed set of constants on purpose and
// its imeta tag has no field for a warning, so the switch would be
// a control with nowhere to put the answer.
showContentWarning = false,
)
}
@@ -471,19 +496,21 @@ private fun CordnGroupChat(
// 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 },
onAttach = { uri ->
uploadState.load(persistentListOf(SelectedMedia(uri, context.contentResolver.getType(uri))))
},
// Recording no longer sends. It goes to the preview above the field,
// where it can be played back, re-recorded or dropped first.
pendingVoice = pendingVoice,
onVoiceNote = { recording ->
scope.launch {
attaching = true
attachError = null
try {
sendVoiceNote(context, accountViewModel, room, recording)
} catch (e: Exception) {
attachError = e.message ?: uploadFailed
} finally {
attaching = false
}
}
// Re-recording replaces what was there; the old file is a temp
// file this screen owns, so it goes now rather than being leaked.
pendingVoice?.file?.delete()
pendingVoice = recording
},
onRemoveVoice = {
pendingVoice?.file?.delete()
pendingVoice = null
},
attaching = attaching,
// The field hands its own text over rather than the screen reading
@@ -491,6 +518,26 @@ private fun CordnGroupChat(
// settles a frame later, and a send must use what is on screen now.
onSend = { typed ->
val text = typed.trim()
val voice = pendingVoice
if (voice != null) {
pendingVoice = null
room.draft.value = ""
scope.launch {
attaching = true
attachError = null
try {
sendVoiceNote(context, accountViewModel, room, voice, text)
} catch (e: Exception) {
Log.w("CordnGroupChat", "voice note failed in ${room.gid}: ${e.message}", e)
attachError = e.message ?: uploadFailed
} finally {
attaching = false
}
}
return@CordnComposer
}
if (text.isEmpty()) return@CordnComposer
val reply = replyingTo
@@ -696,24 +743,24 @@ private class CordnAttachmentException(
) : Exception(message)
/**
* Encrypts the picked file and sends it as an attachment on an empty message.
* Encrypts what the upload dialog is holding and sends it as an attachment.
*
* Reading the bytes into memory rather than streaming: the codec authenticates
* the whole file with one AEAD tag and hashes the plaintext, both of which
* need every byte anyway, and a group chat attachment is not a video archive.
* cordn does not go through [com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator]
* like the nostr surfaces do: its `imeta` tag carries the hash of the *plaintext* and a
* per-file key, which the orchestrator neither produces nor surfaces. So the dialog's
* two byte-level choices are applied here by hand, against the same helpers the
* orchestrator uses, and the encryption and upload stay in [CordnMediaService].
*/
private suspend fun sendAttachment(
context: Context,
accountViewModel: AccountViewModel,
room: CordnGroupChatroom,
picked: PickedAttachment,
caption: String,
state: ChatFileUploadState,
) {
// 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,
// the spinner ended, and no message and no error appeared. Measured on the
// tablet — attaching a PNG produced no message, no error and no log line.
// Each one is now a reason a person can act on.
// the spinner ended, and no message and no error appeared. Each one is now
// a reason a person can act on.
val session =
accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
@@ -721,29 +768,71 @@ private suspend fun sendAttachment(
session.manager.group(room.gid)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_send_no_session))
// 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 orchestrator = state.multiOrchestrator ?: return
val item = orchestrator.get(0)
val uri = item.media.uri
val declaredMime = item.media.mimeType ?: context.contentResolver.getType(uri) ?: CordnBlobUpload.OPAQUE
// The media-quality slider.
val compressed =
item.orchestrator.compressIfNeeded(
uri = uri,
mimeType = declaredMime,
compressionQuality = MediaCompressor.intToCompressorQuality(state.mediaQualitySlider),
context = context,
)
val mime = compressed.contentType ?: declaredMime
// The strip-metadata switch. A file type the stripper does not handle comes back
// untouched and says so, which is not a failure — there was nothing to strip.
val finalUri =
if (state.stripMetadata) {
withContext(Dispatchers.IO) { MetadataStripper.strip(compressed.uri, mime, context) }.uri
} else {
compressed.uri
}
// Name and type come from OpenableColumns: `uri.lastPathSegment` is a document id
// on a content:// URI, not a filename.
val name = resolveDisplayName(context, uri)
val bytes =
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(picked.uri)?.use { it.readBytes() } }
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(finalUri)?.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
// person can change — the one failure here that is entirely actionable,
// and the one that was hardest to notice.
// Null means the chosen host has no base URL, which is a setting the person can
// change — the one failure here that is entirely actionable.
val tag =
CordnMediaService(accountViewModel.account).upload(bytes, mime, name, context)
CordnMediaService(accountViewModel.account)
.upload(bytes, mime, name, context, state.selectedServer.baseUrl)
?: throw CordnAttachmentException(stringRes(context, R.string.cordn_media_no_server))
// 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.
//
// 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)))
// The dialog's description 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 = state.caption.trim(), tags = arrayOf(tag)))
}
/**
* The file's real name.
*
* `uri.lastPathSegment` is a document id on a `content://` URI, not a name — it is what
* every cordn attachment has been named until now.
*/
private fun resolveDisplayName(
context: Context,
uri: Uri,
): String =
runCatching {
context.contentResolver.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('/')
?: "file"
/**
* One attachment, fetched and decrypted on demand.
*
@@ -899,6 +988,7 @@ private suspend fun sendVoiceNote(
accountViewModel: AccountViewModel,
room: CordnGroupChatroom,
recording: RecordingResult,
caption: String,
) {
val session = accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) ?: return
val group = session.manager.group(room.gid) ?: return
@@ -911,7 +1001,7 @@ private suspend fun sendVoiceNote(
// 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)))
room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag)))
} finally {
withContext(Dispatchers.IO) { recording.file.delete() }
}
@@ -38,7 +38,6 @@ 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -57,6 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor
import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout
import com.vitorpamplona.amethyst.commons.cordn.CordnMentions
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -515,19 +515,7 @@ private const val QUOTE_TINT_ALPHA = 0.06f
*/
@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)
}
ChatDivisor(stringRes(R.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary)
}
/**
@@ -740,19 +728,7 @@ internal fun DaySeparator(
)
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalDivider(Modifier.weight(1f))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(Modifier.weight(1f))
}
ChatDivisor(label)
}
/** Upper bound on how long a stale "Today" can survive a clock correction. */
@@ -99,6 +99,14 @@ fun ChatFileUploadDialog(
accountViewModel: AccountViewModel,
nav: INav,
isNip17: Boolean = false,
/**
* Whether the sensitive-content switch is offered.
*
* False for a surface with nowhere to put the answer: cordn's blob host is told a
* fixed set of constants on purpose (see CordnBlobUpload), and its `imeta` tag has
* no field for a warning, so the switch would be a control that does nothing.
*/
showContentWarning: Boolean = true,
) {
val scrollState = rememberScrollState()
@@ -150,7 +158,7 @@ fun ChatFileUploadDialog(
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel, isNip17)
ImageVideoPostChat(state, accountViewModel, isNip17, showContentWarning)
}
}
}
@@ -163,6 +171,7 @@ private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
isNip17: Boolean = false,
showContentWarning: Boolean = true,
) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState()
@@ -201,13 +210,15 @@ private fun ImageVideoPostChat(
),
)
SettingSwitchItem(
title = Res.string.add_sensitive_content_label,
description = Res.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
if (showContentWarning) {
SettingSwitchItem(
title = Res.string.add_sensitive_content_label,
description = Res.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.contentWarning,
onCheckedChange = fileUploadState::updateContentWarning,
)
}
if (isNip17) {
SettingSwitchItem(
-3
View File
@@ -368,9 +368,6 @@
<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>
@@ -21,11 +21,14 @@
package com.vitorpamplona.amethyst.commons.chats.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP
@@ -33,21 +36,32 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding
@Composable
fun ChatDivisor(info: String) {
fun ChatDivisor(
info: String,
/**
* Tints both rules and the label. Unspecified keeps the default, which is every
* date divisor; an unread marker passes the accent so the line it draws reads as a
* status rather than another date.
*/
color: Color = Color.Unspecified,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) {
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
color = if (color.isSpecified) color else DividerDefaults.color,
)
Text(
text = info,
fontWeight = FontWeight.Bold,
fontSize = Font14SP,
color = color,
modifier = HalfPadding,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
color = if (color.isSpecified) color else DividerDefaults.color,
)
}
}