diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt index c296f2ad27..cf6f4b1973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt @@ -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? = 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt deleted file mode 100644 index 82ca185b35..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnAttachmentDialog.kt +++ /dev/null @@ -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(null, uri) { - value = withContext(Dispatchers.IO) { resolvePickedAttachment(context, uri) } - } - val resolved = picked - - AlertDialog( - // A dismiss mid-upload would leave the dialog's own progress unobservable - // while the work carried on, so it only closes when it is idle. - onDismissRequest = { if (!sending) onDismiss() }, - title = { Text(stringRes(R.string.cordn_attachment_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (resolved != null) AttachmentPreview(resolved) - - OutlinedTextField( - value = caption, - onValueChange = { caption = it }, - enabled = !sending, - placeholder = { Text(stringRes(R.string.cordn_attachment_caption_hint)) }, - modifier = Modifier.fillMaxWidth(), - ) - - if (sending) { - // Indeterminate on purpose: the upload is one suspend call that - // encrypts and posts, and it reports nothing in between. A bar - // that invented a percentage would be a lie about what is known. - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringRes(R.string.cordn_attachment_uploading), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { resolved?.let { onSend(it, caption) } }, - enabled = !sending && resolved != null, - ) { - Text(stringRes(R.string.cordn_send)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss, enabled = !sending) { - Text(stringRes(Res.string.cancel)) - } - }, - ) -} - -/** A thumbnail for an image, the name and type for everything else. */ -@Composable -private fun AttachmentPreview(picked: PickedAttachment) { - val context = LocalContext.current - - val thumbnail by - produceState(null, picked.uri) { - value = - if (picked.mimeType.startsWith("image/")) { - withContext(Dispatchers.IO) { decodeThumbnail(context, picked.uri) } - } else { - null - } - } - - val shown = thumbnail - if (shown != null) { - Image( - bitmap = shown, - contentDescription = picked.displayName, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp), - ) - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = picked.displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = picked.mimeType, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } -} - -/** Longest edge of the preview bitmap. A dialog never needs the full image. */ -private const val THUMBNAIL_MAX_EDGE = 1024 - -/** - * Decodes [uri] small enough to preview. - * - * Bounds first, then a subsampled decode: a modern phone photo decoded at full size is - * tens of megabytes, and this runs to draw something a few hundred pixels tall. - */ -private fun decodeThumbnail( - context: Context, - uri: Uri, -): ImageBitmap? = - runCatching { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } - - var sample = 1 - while (bounds.outWidth / sample > THUMBNAIL_MAX_EDGE || bounds.outHeight / sample > THUMBNAIL_MAX_EDGE) { - sample *= 2 - } - - val options = BitmapFactory.Options().apply { inSampleSize = sample } - context.contentResolver - .openInputStream(uri) - ?.use { BitmapFactory.decodeStream(it, null, options) } - ?.asImageBitmap() - }.getOrNull() - -/** - * The file's real name and type. - * - * `uri.lastPathSegment` is a document id on a `content://` URI, not a name — it is what - * the room used to show for every attachment, and what the dialog would otherwise put - * in front of the person confirming the send. - */ -internal fun resolvePickedAttachment( - context: Context, - uri: Uri, -): PickedAttachment { - val resolver = context.contentResolver - val mime = resolver.getType(uri) ?: OPAQUE_MIME - - val name = - runCatching { - resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> - if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null - } - }.getOrNull() - ?: uri.lastPathSegment?.substringAfterLast('/') - ?: FALLBACK_NAME - - return PickedAttachment(uri, name, mime) -} - -private const val OPAQUE_MIME = "application/octet-stream" -private const val FALLBACK_NAME = "file" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt index 285248a232..39965790fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -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 -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index ccac9562e9..dbac69f3d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -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(null) } var editing by remember { mutableStateOf(null) } var attaching by remember { mutableStateOf(false) } - var pendingAttachment by remember { mutableStateOf(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(null) } var attachError by remember { mutableStateOf(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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt index 6b419bc377..62f5295b4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -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. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index a87d69fba2..f1b33cc788 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e1a0d78d39..dc0c2e1e55 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -368,9 +368,6 @@ Yesterday New messages Reactions - Send file - Add a caption - Uploading\u2026 Add a coordinator in Settings to start using cordn groups. Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked. Look for coordinators diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt index d6061a875f..a9988fb4cd 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt @@ -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, ) } }