mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
-246
@@ -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"
|
||||
+33
-1
@@ -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 ->
|
||||
|
||||
+134
-44
@@ -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() }
|
||||
}
|
||||
|
||||
+3
-27
@@ -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. */
|
||||
|
||||
+19
-8
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
+15
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user