diff --git a/.claude/hooks/compose_escaping_check.py b/.claude/hooks/compose_escaping_check.py index b5721481ae..4116c90bf1 100755 --- a/.claude/hooks/compose_escaping_check.py +++ b/.claude/hooks/compose_escaping_check.py @@ -66,6 +66,11 @@ def find_violations(root: Path): ) if n: found[path]["raw line break/tab in a value"] += n + # Android's printf escape. Compose substitutes only %N$s / %N$d, so `%%` + # renders as two percent signs ("100%% uptime"). + n = sum(m.group(2).count("%%") for m in STRING_TEXT.finditer(text)) + if n: + found[path]["%%"] += n return found diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3ae1d169d5..b77738a2d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1721,7 +1721,15 @@ class Account( marmot.deleteMarmotMessages(groupId, groupNotes) } - val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null } + val (myRumors, myNotes) = + otherNotes + .filter { it.author == userProfile() && it.event != null } + .partition { it.isPrivateRumor() } + + // Private rumors (NIP-17 DMs, private reactions) are retracted inside their own + // conversation for the same reason, whichever caller asked for a plain delete. + myRumors.forEach { deletePrivately(listOf(it), it) } + if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNotes.chunked(200).forEach { chunkedList -> @@ -3979,9 +3987,9 @@ class Account( // assertion by its sender and is dropped at ingest — so these are // safe to render with attribution. marmotManager.onSystemRowDerived = { groupId, row -> - cache.justConsume(row, null, true) - val note = cache.getOrCreateNote(row.id) - note.event = row + // Indexed like any inner event: a bare `note.event = row` left the row with no + // author, which the Messages tab read as "No messages yet". + val note = marmot.indexMarmotInnerEvent(row).note marmotGroupList.addMessage(groupId, note) } @@ -4048,6 +4056,7 @@ class Account( // kind:1009 edit is re-linked to its message too. val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note marmotGroupList.addMessage(groupId, innerNote) + marmot.applyMarmotAdminRemoval(groupId, innerEvent) } catch (e: Exception) { Log.w( "Account", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 15df5f3552..4e2d9a7f8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -88,6 +91,33 @@ class AccountMarmotActions( /** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */ private var lastOwnerCheck: Pair? = null + // Welcome rumor ids with a retry already scheduled, so a relay re-delivering the same + // wrap while one waits does not start a second chain of retries. + private val welcomeRetries = mutableSetOf() + + // Welcome rumor ids whose retries ran out this session. Relays re-deliver two days of gift + // wraps on every re-subscription, and each delivery used to start a fresh chain, so a + // Welcome that can never apply was re-run through MLS for as long as relays kept it. + // In memory on purpose: the next app start still gives it one more try. + private val welcomeRetriesExhausted = mutableSetOf() + private val welcomeRetriesLock = KmpLock() + + /** True when [welcomeId] had no retry pending, has retries left, and now has one pending. */ + fun claimWelcomeRetry(welcomeId: HexKey): Boolean = + welcomeRetriesLock.withLock { + welcomeId !in welcomeRetriesExhausted && welcomeRetries.add(welcomeId) + } + + fun markWelcomeRetriesExhausted(welcomeId: HexKey) { + welcomeRetriesLock.withLock { welcomeRetriesExhausted.add(welcomeId) } + } + + fun welcomeRetriesExhausted(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeId in welcomeRetriesExhausted } + + fun releaseWelcomeRetry(welcomeId: HexKey) { + welcomeRetriesLock.withLock { welcomeRetries.remove(welcomeId) } + } + /** * Resolve the relay set for a Marmot group. Prefer the relays carried in * the MLS GroupContext metadata so every member converges on the same @@ -128,12 +158,11 @@ class AccountMarmotActions( nostrGroupId: HexKey, innerEvent: Event, ) { - // wasVerified=true: MIP-03 inner events are unsigned rumors, so - // Schnorr verification would reject every one. This one we built - // ourselves, which is as authenticated as it gets. - val isNew = account.cache.justConsume(innerEvent, null, true) - val innerNote = account.cache.getOrCreateNote(innerEvent.id) - if (isNew) innerNote.event = innerEvent + // The same indexing as a received message. A bare justConsume returns false for a kind + // LocalCache does not dispatch (push-token lists 447-449, edits, stream starts), which + // left our own copy an EVENTLESS note in the conversation: an "Event is loading or can't + // be found" row for every push-token answer we sent. + val innerNote = indexMarmotInnerEvent(innerEvent).note account.marmotGroupList.addMessage(nostrGroupId, innerNote) // Sending a message moves the group out of "New Requests" into // "Known" — do this eagerly before the relay round-trip so the UI @@ -284,6 +313,21 @@ class AccountMarmotActions( return IndexedInnerEvent(innerNote, isNew) } + /** + * Apply a kind-4891 admin removal: drop the messages it names from the conversation + * when its author is an admin of the group (see [MarmotManager.adminRemovalTargets]). + * Runs on live delivery and on the restart replay, which re-adds every stored message. + */ + fun applyMarmotAdminRemoval( + nostrGroupId: HexKey, + innerEvent: Event, + ) { + val manager = account.marmotManager ?: return + manager.adminRemovalTargets(nostrGroupId, innerEvent).forEach { targetId -> + account.marmotGroupList.applyAdminRemoval(nostrGroupId, targetId, account.cache.getNoteIfExists(targetId)) + } + } + /** [note] holds the inner event; [isNew] is true the first time this client indexed it. */ class IndexedInnerEvent( val note: Note, @@ -591,6 +635,21 @@ class AccountMarmotActions( lastOwnerCheck = null } + /** + * Drop this device's copy of a group it has fallen out of sync with, and make sure a + * fresh KeyPackage is out there for the admin's re-invite. See + * [MarmotManager.resetOutOfSyncGroup]. + */ + suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + manager.resetOutOfSyncGroup(nostrGroupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) + chatroom.isOutOfSync.value = false + chatroom.awaitingReinvite.value = true + account.marmotGroupList.notifyGroupChanged(nostrGroupId) + ensureMarmotKeyPackagePublished() + } + /** * Ensure the local user has at least one active KeyPackage bundle and * a published KeyPackage event on relays. Called from [init] after @@ -769,6 +828,18 @@ class AccountMarmotActions( // Creator owns the group — mark it as "known" immediately so it // doesn't appear under "New Requests" before the first message. account.marmotGroupList.markAsKnown(nostrGroupId) + syncAndNotify(nostrGroupId) + } + + /** + * Copy the group's current MLS state (name, admins, members, relays) into its + * chatroom and tell the list to re-render it. For actions that commit outside the + * paths here which already sync. + */ + fun syncAndNotify(nostrGroupId: HexKey) { + val manager = account.marmotManager ?: return + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) + account.marmotGroupList.notifyGroupChanged(nostrGroupId) } /** @@ -951,13 +1022,40 @@ class AccountMarmotActions( * * The endpoints come from the account's own Blossom server list, because a * policy naming servers the uploader does not use would describe a group - * nobody can actually post media to. + * nobody can actually post media to. An account without one uploads to the + * default list (the same one the upload picker offers and falls back + * through), so that is what its policy names. */ + fun marmotMediaPolicyServers(): List = + account.blossomServers.flow.value + .ifEmpty { + account.blossomServers.hostNameFlow.value + .filter { it.type == ServerType.Blossom } + .map { it.baseUrl } + }.mapNotNull { normalizedPolicyBaseUrl(it) } + .distinct() + .take(EncryptedMediaPolicyV2.MAX_ENTRIES) + + /** + * [url] in the byte-exact form the media policy requires, or null when it has none. + * The component rejects a base URL that isn't its own WHATWG serialization, so the + * everyday spelling without a trailing slash (`https://cdn.nostrcheck.me`) failed the + * whole commit. + */ + private fun normalizedPolicyBaseUrl(url: String): String? = + try { + MarmotWebUrl.normalize(url, allowHttp = true, label = "base_url").also { + EncryptedMediaPolicyV2.requireNormalizedBaseUrl(it) + } + } catch (e: IllegalArgumentException) { + null + } + suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) { val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val servers = account.blossomServers.flow.value + val servers = marmotMediaPolicyServers() require(servers.isNotEmpty()) { "Cannot enable encrypted media without at least one Blossom server configured" } @@ -1018,6 +1116,9 @@ class AccountMarmotActions( if (view.adminPubkeys.contains(targetPubKey)) return manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList()) + // The commit is canonical once published; without this the roster and the + // admin badges keep the pre-commit admin list until our own echo or a restart. + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) } /** @@ -1042,5 +1143,8 @@ class AccountMarmotActions( } manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList()) + // The commit is canonical once published; without this the roster and the + // admin badges keep the pre-commit admin list until our own echo or a restart. + manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index fa73ff1268..4abc711481 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -37,7 +40,9 @@ import kotlinx.coroutines.launch class MultiOrchestrator( uris: List, ) { - private var list: List = uris.map { SelectedMediaProcessing(it) } + // Snapshot state so a removal recomposes the gallery that draws it; as a plain var the + // Remove on a failed upload dropped the item from the upload but left it on screen. + private var list: List by mutableStateOf(uris.map { SelectedMediaProcessing(it) }) @Stable class Result( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 5728bab2e0..bf59f45883 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required @@ -202,6 +203,58 @@ class UploadOrchestrator { } } + /** + * Tries [selected], then the rest of the servers the picker offered (see + * [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they + * share one signed upload token: the signer is asked once, and a signer that refused, timed + * out or is read-only stops the fallback instead of prompting again per server. Attempts + * don't publish their errors; when every server fails, the error shown is the selected + * server's, the one the user chose and will recognize. + */ + private suspend fun uploadBlossomWithFallback( + selected: ServerName, + account: Account, + uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState, + ): UploadingFinalState { + val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value) + val auth = SharedUploadAuth() + var firstError: UploadingState.Error? = null + for (server in order) { + when (val result = uploadTo(server.baseUrl, auth)) { + is UploadingState.Finished -> return result + is UploadingState.Error -> { + if (firstError == null) firstError = result + if (auth.signerFailed) return result.also { updateState(0.0, it) } + Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server") + } + } + } + return firstError!!.also { updateState(0.0, it) } + } + + /** + * The upload token for one blob, signed at most once and reused by every server tried. + * A signer that returned no token is asked again on the next server; only a token or a + * signer failure is kept. + */ + private class SharedUploadAuth { + private var outcome: Result? = null + + var signerFailed = false + private set + + suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? { + outcome?.let { return it.getOrThrow() } + return try { + sign()?.also { outcome = Result.success(it) } + } catch (e: SignerExceptions) { + signerFailed = true + outcome = Result.failure(e) + throw e + } + } + } + private suspend fun uploadBlossom( fileUri: Uri, contentType: String?, @@ -214,6 +267,7 @@ class UploadOrchestrator { account: Account, forcedSigner: NostrSigner?, context: Context, + sharedAuth: SharedUploadAuth, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) // BUD-05: route through /media (optimize) when the user opted in. The forced-signer @@ -235,12 +289,15 @@ class UploadOrchestrator { // upload path: some servers reject an upload whose auth carries a // `server` tag, and upload-token replay is not the threat scoping // guards against (delete tokens are — those stay scoped). - httpAuth = - when { - forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } - useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) } - else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) } - }, + httpAuth = { hash, size, alt -> + sharedAuth.get { + when { + forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) + useMedia -> account.createBlossomMediaAuth(hash, size, alt) + else -> account.createBlossomUploadAuth(hash, size, alt) + } + } + }, context = context, useMediaEndpoint = useMedia, ) @@ -271,13 +328,13 @@ class UploadOrchestrator { finalState } catch (_: SignerExceptions.ReadOnlyException) { - error(Res.string.login_with_a_private_key_to_be_able_to_upload) + UploadingState.Error(Res.string.login_with_a_private_key_to_be_able_to_upload, emptyArray()) } catch (e: BlossomPaymentException) { // BUD-07: the server wants payment before it will store the blob. - error(Res.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl) + UploadingState.Error(Res.string.blossom_payment_required, arrayOf(e.payment.reason ?: serverBaseUrl)) } catch (e: Exception) { if (e is CancellationException) throw e - error(Res.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName) + UploadingState.Error(Res.string.failed_to_upload_media, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName)) } } @@ -482,7 +539,10 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl, auth -> + uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth) + } } } finally { deleteTempUri(finalUri, uri) @@ -527,7 +587,12 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + // The same encrypted file goes to every server tried, so its key, nonce and + // hash stay valid whichever one ends up holding it. + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl, auth -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth) + } } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index cb9aedbd85..2998dc0ce6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -330,6 +330,9 @@ open class EditPostViewModel : ViewModel() { } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 6c58b3d639..c6aa0ea930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -237,7 +237,10 @@ open class NewMediaModel : ViewModel() { } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt index 56e889b505..9064d7ecf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt @@ -28,6 +28,7 @@ import android.os.Build import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,11 +39,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -64,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.close +import com.vitorpamplona.amethyst.commons.resources.remove +import com.vitorpamplona.amethyst.commons.resources.upload_error_title import com.vitorpamplona.amethyst.commons.resources.uploading_state_compressing import com.vitorpamplona.amethyst.commons.resources.uploading_state_downloading import com.vitorpamplona.amethyst.commons.resources.uploading_state_error @@ -216,10 +222,55 @@ fun OrchestratorOverlay( val progress by orchestrator.progress.collectAsState() val progressState by orchestrator.progressState.collectAsState() - if (progressState is UploadingState.Ready) { + val state = progressState + if (state is UploadingState.Ready) { DeleteButton(onDelete) + } else if (state is UploadingState.Error) { + UploadErrorBadge(progress, state, onDelete) } else { - UploadingState(progress, progressState) + UploadingState(progress, state) + } +} + +/** + * A failed upload: the badge alone has room for one word, so tapping it opens the reason + * (server refused the file type, payment required, no connection, ...). Without it the + * user had "Error" and nothing to act on, and no way to drop the failed attachment. + */ +@Composable +private fun UploadErrorBadge( + progress: Double, + state: UploadingState.Error, + onDelete: () -> Unit, +) { + var showDetails by remember { mutableStateOf(false) } + + Box( + modifier = + Modifier + .fillMaxSize() + .clickable { showDetails = true }, + ) { + UploadingState(progress, state) + } + + if (showDetails) { + AlertDialog( + onDismissRequest = { showDetails = false }, + title = { Text(stringRes(Res.string.upload_error_title)) }, + text = { Text(stringRes(state.errorResource, *state.params)) }, + confirmButton = { + TextButton(onClick = { showDetails = false }) { Text(stringRes(Res.string.close)) } + }, + dismissButton = { + TextButton( + onClick = { + showDetails = false + onDelete() + }, + ) { Text(stringRes(Res.string.remove)) } + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt index 01dd19bfff..279d636de8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState @Composable fun UploadProgressIndicator( @@ -60,12 +62,12 @@ fun UploadProgressIndicator( val progressValue = orchestrator.progress.collectAsState().value val progressStatusValue = orchestrator.progressState.collectAsState().value - Box( + Column( modifier = modifier .fillMaxWidth() .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier.size(55.dp), @@ -88,14 +90,14 @@ fun UploadProgressIndicator( val txt = when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(Res.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(Res.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(Res.string.uploading_state_error) + is UploadingState.Ready -> stringRes(Res.string.uploading_state_ready) + is UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing) + is UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading) + is UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing) + is UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading) + is UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing) + is UploadingState.Finished -> stringRes(Res.string.uploading_state_finished) + is UploadingState.Error -> stringRes(Res.string.uploading_state_error) } Text( @@ -105,5 +107,16 @@ fun UploadProgressIndicator( textAlign = TextAlign.Center, ) } + + // The badge fits one word; the reason is what the user can act on. + if (progressStatusValue is UploadingState.Error) { + Text( + stringRes(progressStatusValue.errorResource, *progressStatusValue.params), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp), + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 27c3f60fd7..c0bbf9a613 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.resources.quick_action_follow import com.vitorpamplona.amethyst.commons.resources.quick_action_mute_thread import com.vitorpamplona.amethyst.commons.resources.quick_action_report import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body +import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_group +import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_private import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.resources.quick_action_share import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link @@ -436,7 +438,7 @@ fun CardBody( stringRes(Res.string.quick_action_delete), ) { if (accountViewModel.account.settings.hideDeleteRequestDialog) { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) onDismiss() } else { showDeleteAlertDialog.value = true @@ -579,6 +581,21 @@ fun NoteQuickActionItem( } } +/** + * What a deletion actually does for [note]: a public NIP-09 to relays, a request sent + * inside a Marmot group, or a gift-wrapped request to a NIP-17 conversation. The public + * wording ("deleted from the relays you are connected to") is wrong for the last two. + */ +fun deletionRequestBody( + note: Note, + accountViewModel: AccountViewModel, +): StringResource = + when { + accountViewModel.account.marmot.marmotGroupOf(note) != null -> Res.string.quick_action_request_deletion_alert_body_group + note.isPrivateRumor() -> Res.string.quick_action_request_deletion_alert_body_private + else -> Res.string.quick_action_request_deletion_alert_body + } + @Composable fun DeleteAlertDialog( note: Note, @@ -587,15 +604,15 @@ fun DeleteAlertDialog( ) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) onDismiss() }, onClickDontShowAgain = { - accountViewModel.delete(note) + accountViewModel.deleteOwn(note) accountViewModel.account.settings.setHideDeleteRequestDialog() onDismiss() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 20c521b79d..6ad6df6372 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -427,7 +427,7 @@ fun ImageVideoDescription( } } - if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) { + if (uris.hasVideo() && mediaQualitySlider != 3) { SettingSwitchItem( title = Res.string.video_codec_h265_label, description = Res.string.video_codec_h265_description, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index fd09217ed1..313da6e77e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.note_actions_dialog_title import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn -import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable @@ -47,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.note.deletionRequestBody import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog @@ -163,20 +163,12 @@ fun NoteDropDownMenu( ) } - // Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped - // deletion — a public NIP-09 would e-tag the rumor id onto public relays. - val performDelete = { - if (note.isPrivateRumor()) { - accountViewModel.deletePrivately(note) - } else { - accountViewModel.delete(note) - } - } + val performDelete = { accountViewModel.deleteOwn(note) } if (deleteConfirmationShowing) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index 958acad531..7d6719b11b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -159,14 +159,15 @@ fun noteActionSections( }, ) } - } - add( - NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) { - note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) } - handlers.onDismiss() - }, - ) + // Your own posts: there is no "author" to follow or file into a list. + add( + NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) { + note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) } + handlers.onDismiss() + }, + ) + } } // When the rendered note was translated, Copy Text opens a chooser (Copy diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 71616b6476..656bcf3afa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -937,7 +937,10 @@ open class CommentPostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 80bfb805b6..f92eae835c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -596,6 +596,15 @@ class AccountViewModel( reactToOrDelete(note, reaction) } + /** + * Retracts one of your own notes the way its transport requires. Private rumors (NIP-17 DMs, + * Marmot group messages) take a private deletion; a public NIP-09 would e-tag the rumor id + * onto public relays. + */ + fun deleteOwn(note: Note) { + if (note.isPrivateRumor()) deletePrivately(note) else delete(note) + } + /** * Retracts the user's own private rumor (e.g. a NIP-17 DM message) with a * gift-wrapped NIP-09 deletion to the same participants. A public deletion @@ -2464,6 +2473,23 @@ class AccountViewModel( deliverMarmotGroupMessage(nostrGroupId, innerEvent) } + /** + * Replace the text of my own Marmot message [target] with a kind:1009 edit. The edit + * overlays the original everywhere it is shown (here, and in White Noise), and goes + * through the same show-then-publish path as a new message, so a failed send shows on + * the edit's delivery state rather than silently. + */ + suspend fun sendMarmotGroupMessageEdit( + nostrGroupId: String, + target: Note, + text: String, + ) { + val tagger = NewMessageTagger(text, null, null, this) + tagger.run() + val manager = account.marmotManager ?: return + deliverMarmotGroupMessage(nostrGroupId, manager.buildMessageEditRumor(target.idHex, tagger.message)) + } + /** * Show [innerEvent] in the group's chat now and publish it on the account * scope. Shared by every Marmot send that originates in the UI. @@ -2528,9 +2554,16 @@ class AccountViewModel( fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null /** True when this account has somewhere to upload a group's encrypted media. */ - fun hasBlossomServers(): Boolean = - account.blossomServers.flow.value - .isNotEmpty() + fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() + + /** + * On the account scope, not the screen's: the reset drops local state, flags the group as + * awaiting a re-invite and publishes a KeyPackage, and leaving the chat mid-way must not + * stop it between those steps. + */ + fun resetOutOfSyncMarmotGroup(nostrGroupId: String) { + account.scope.launch(Dispatchers.IO) { account.marmot.resetOutOfSyncMarmotGroup(nostrGroupId) } + } suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) @@ -2706,6 +2739,9 @@ class AccountViewModel( relays.toList(), ) } + // The commits are canonical once published. Surface them now: a freshly created + // group otherwise sat at "0 members" with no name until our own echo or a restart. + account.marmot.syncAndNotify(nostrGroupId) } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 83d907fb90..b9924db662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -394,11 +397,17 @@ class GiftWrapEventHandler( private suspend fun processMarmotWelcomeFlow( innerEvent: Event, account: Account, + attempt: Int = 0, ) { val manager = account.marmotManager ?: return if (innerEvent !is WelcomeEvent) { return } + // A Welcome that already joined (or never can) is done. Replays of its wrap are routine: + // every re-subscription re-delivers the last two days of gift wraps. + if (manager.isTerminallyIngested(innerEvent.id)) return + // Out of retries this session: a re-delivered wrap waits for the next app start. + if (attempt == 0 && account.marmot.welcomeRetriesExhausted(innerEvent.id)) return // "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it. // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead. @@ -407,12 +416,14 @@ private suspend fun processMarmotWelcomeFlow( when (result) { is WelcomeResult.Joined -> { + manager.markTerminallyIngested(innerEvent.id) Log.d("MarmotDbg") { "processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" } // Sync MIP-01 metadata from group extensions to chatroom val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + chatroom.awaitingReinvite.value = false manager.syncMetadataTo(result.nostrGroupId, chatroom) Log.d("MarmotDbg") { "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + @@ -437,6 +448,7 @@ private suspend fun processMarmotWelcomeFlow( } is WelcomeResult.AlreadyJoined -> { + manager.markTerminallyIngested(innerEvent.id) // Benign replay of a gift-wrapped Welcome (kind:1059) we already // processed in a prior session — the relay is just re-delivering // it after app restart. Log at DEBUG, not WARN. @@ -446,11 +458,42 @@ private suspend fun processMarmotWelcomeFlow( } is WelcomeResult.Error -> { - Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" } + Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR (attempt ${attempt + 1}) ${result.message}" } + if (result.message.contains("No matching KeyPackageBundle")) { + // Bundles are generated before their KeyPackage is published, so a Welcome + // for one we never held can never become processable. + manager.markTerminallyIngested(innerEvent.id) + } else { + scheduleWelcomeRetry(innerEvent, account, attempt) + } } } } +/** + * Backoff for a Welcome that failed for a reason that can pass (the signer was busy, the + * KeyPackage store was still loading, a relay round trip failed). Before this a failed + * Welcome was only retried on the next app start: a replayed wrap skipped the flow. + */ +private val WELCOME_RETRY_DELAYS_MS = longArrayOf(30_000L, 120_000L, 600_000L) + +private fun scheduleWelcomeRetry( + welcome: WelcomeEvent, + account: Account, + attempt: Int, +) { + if (attempt >= WELCOME_RETRY_DELAYS_MS.size) { + account.marmot.markWelcomeRetriesExhausted(welcome.id) + return + } + if (!account.marmot.claimWelcomeRetry(welcome.id)) return + account.scope.launch(Dispatchers.IO) { + delay(WELCOME_RETRY_DELAYS_MS[attempt]) + account.marmot.releaseWelcomeRetry(welcome.id) + processMarmotWelcomeFlow(welcome, account, attempt + 1) + } +} + class SealEventHandler( private val account: Account, private val cache: LocalCache, @@ -529,7 +572,13 @@ class SealEventHandler( cache.copyRelaysFromTo(publicNote, rumorId) val innerRumorNote = cache.getOrCreateNote(rumorId) innerRumorNote.event?.let { innerRumor -> - eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + // A re-delivered Welcome goes back through the MLS flow, which returns at once + // when it already joined; one that failed the first time gets another chance. + if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) { + processMarmotWelcomeFlow(innerRumor, account) + } else { + eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote) + } } } } @@ -647,6 +696,10 @@ class GroupEventHandler( when (result) { is GroupEventResult.ApplicationMessage -> { + // A message that decrypts means this device is back in step. + account.marmotGroupList + .getOrCreateGroup(result.groupId) + .isOutOfSync.value = false // Parse the inner event JSON and index it val innerEvent = Event.fromJson(result.innerEventJson) Log.d("MarmotDbg") { @@ -723,6 +776,7 @@ class GroupEventHandler( // peer-sent kind:1210 is dropped inside addMessage — see // `MarmotGroupList.isDisplayableFeedMessage`. account.marmotGroupList.addMessage(result.groupId, innerNote) + account.marmot.applyMarmotAdminRemoval(result.groupId, innerEvent) // Traffic is the natural clock for disappearing messages: a // group being read is a group whose expired messages should @@ -827,6 +881,14 @@ class GroupEventHandler( Log.d("MarmotDbg") { "GroupEventHandler.add: ProposalStaged group=${result.groupId.take(8)}… senderLeaf=${result.senderLeafIndex}" } + // A departure only takes effect once an admin commits it; if that is us, do it. + val groupId = result.groupId + account.scope.launch(Dispatchers.IO) { + if (manager.commitStagedProposalsIfAdmin(groupId) != null) { + manager.syncMetadataTo(groupId, account.marmotGroupList.getOrCreateGroup(groupId)) + account.marmotGroupList.notifyGroupChanged(groupId) + } + } } is GroupEventResult.AppMessageOnCandidateBranch -> { @@ -853,7 +915,12 @@ class GroupEventHandler( } is GroupEventResult.Error -> { - Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } + Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR group=${result.groupId?.take(8)} ${result.message}" } + result.groupId?.let { groupId -> + val outOfSync = manager.isOutOfSync(groupId) + val chatroom = account.marmotGroupList.getOrCreateGroup(groupId) + if (chatroom.isOutOfSync.value != outOfSync) chatroom.isOutOfSync.value = outOfSync + } } } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 72b483ee4e..85cdabb4ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -72,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error import com.vitorpamplona.amethyst.commons.resources.more_options import com.vitorpamplona.amethyst.commons.resources.no_wallet_found import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn -import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message @@ -94,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.note.RenderReaction import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid +import com.vitorpamplona.amethyst.ui.note.deletionRequestBody import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams @@ -146,20 +146,12 @@ fun ChatMessageActionSheet( var concordBanConfirming by remember { mutableStateOf(false) } var showDeliveryDialog by remember { mutableStateOf(false) } - // Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped - // deletion — a public NIP-09 would e-tag the rumor id onto public relays. - val performDelete = { - if (note.isPrivateRumor()) { - accountViewModel.deletePrivately(note) - } else { - accountViewModel.delete(note) - } - } + val performDelete = { accountViewModel.deleteOwn(note) } if (deleteConfirmationShowing) { QuickActionAlertDialog( title = stringRes(Res.string.quick_action_request_deletion_alert_title), - textContent = stringRes(Res.string.quick_action_request_deletion_alert_body), + textContent = stringRes(deletionRequestBody(note, accountViewModel)), buttonIcon = MaterialSymbols.Delete, buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), onClickDoOnce = { @@ -253,7 +245,8 @@ fun ChatMessageActionSheet( isPrivateBookmarkNote = false, isPublicBookmarkNote = false, isPinnedNote = false, - isLoggedUser = false, + // Seeded from the note so the first frame doesn't flash author actions on your own message. + isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = false, showSensitiveContent = null, ), @@ -296,7 +289,15 @@ fun ChatMessageActionSheet( note.event is ChatEvent && isMine && note.inGatherers?.any { it is ConcordChannel } == true - if (canEditBuzz || canEditConcord) { + // Marmot: my own text message in a group -> a kind-1009 edit inside the group. A + // media message's content is its locator, so editing it would break the attachment. + val canEditMarmot = + onWantsToEditChatMessage != null && + note.event is ChatEvent && + isMine && + note.event?.tags?.none { it.isNotEmpty() && it[0] == "imeta" } == true && + accountViewModel.account.marmot.marmotGroupOf(note) != null + if (canEditBuzz || canEditConcord || canEditMarmot) { SectionDivider() TileRow { val label = if (canEditBuzz) Res.string.buzz_edit_message else Res.string.edit_message diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index ab207a6f28..3d180334b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -241,9 +241,10 @@ fun NormalChatNote( // A geohash chat asks own messages to still show the author line (which identity posted), so the // usual "hide the name on my own bubbles" shortcut is opt-out there. val showSelfAuthorName = LocalChatShowSelfAuthorName.current + val isOneOnOne = LocalChatIsOneOnOne.current val drawAuthorInfo by - remember(note, isLoggedInUser, showSelfAuthorName) { + remember(note, isLoggedInUser, showSelfAuthorName, isOneOnOne) { derivedStateOf { val noteEvent = note.event when { @@ -251,6 +252,9 @@ fun NormalChatNote( // which never draws the user's own author info). isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent + // The screen knows it's just the two of you. + isOneOnOne -> false + // never shows the user's pictures noteEvent is EncryptedDmEvent -> false @@ -505,6 +509,14 @@ val LocalChatDisplayNameResolver = compositionLocalOf<((Note) -> String?)?> { nu */ val LocalChatShowSelfAuthorName = compositionLocalOf { false } +/** + * Whether the conversation is known to be one-on-one even though its messages don't say so. + * A NIP-17 message carries its participants ([ChatroomKeyable]) and hides the other person's + * name and picture when there is only one; a Marmot group's inner kind:9 carries nothing, so the + * screen, which knows the member count, says it here. + */ +val LocalChatIsOneOnOne = compositionLocalOf { false } + /** * The geohash of the location room currently open, or null outside one. Every message in that room * repeats the room's own cell in its `g` tag, so the bubble footer suppresses this one geohash — diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 67eb18089d..cae9a3f162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -67,9 +67,11 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException @Composable fun CreateGroupScreen( @@ -136,6 +138,17 @@ fun CreateGroupScreen( description = groupDescription.trim(), icon = iconChange, ) + // Commit the encrypted-media policy while we are the only member. White Noise + // (MDK) refuses to send any attachment in a group without one + // ("group does not require encrypted media"), and joiners learn it from the + // Welcome, so nobody has to apply a later commit to be able to share media. + // Best-effort: a group without it still works for text and legacy MIP-04. + try { + accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("CreateGroupScreen") { "Could not enable encrypted media for $nostrGroupId: ${e.message}" } + } nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class) } catch (e: Exception) { isCreating = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 6b60e7c9cf..8a90a9ee79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -71,7 +71,7 @@ fun MarmotGroupChatScreen( val displayName by chatroom.displayName.collectAsStateWithLifecycle() val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() - val memberPubkeys = remember(members) { members.map { it.pubkey } } + val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } DisappearingScaffold( isInvertedLayout = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 4269133bc2..f31fc28a75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -28,10 +28,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -52,11 +55,17 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_removed import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name import com.vitorpamplona.amethyst.commons.resources.marmot_not_a_member +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title +import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset import com.vitorpamplona.amethyst.commons.resources.reply_here import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -75,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatIsOneOnOne import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader @@ -82,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm 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.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList @@ -157,18 +168,24 @@ fun MarmotGroupChatView( .fillMaxHeight() .weight(1f, true), ) { - RefreshingChatroomFeedView( - feedContentState = feedViewModel.feedState, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), - onWantsToReply = { note -> newMessageModel.reply(note) }, - onWantsToEditDraft = { }, - // kind:1210 rows sit in the conversation in order but are - // group-state captions rather than messages, so they get their - // own centered style instead of a bubble. - rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) }, - ) + // Two members is a 1:1 chat: drop the other person's name and picture from every + // bubble, as a NIP-17 conversation does. Zero means not loaded yet, so keep them. + val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() + CompositionLocalProvider(LocalChatIsOneOnOne provides (memberCount in 1..2)) { + RefreshingChatroomFeedView( + feedContentState = feedViewModel.feedState, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), + onWantsToReply = { note -> newMessageModel.reply(note) }, + onWantsToEditDraft = { }, + onWantsToEditChatMessage = { note -> newMessageModel.editMarmotMessage(note) }, + // kind:1210 rows sit in the conversation in order but are + // group-state captions rather than messages, so they get their + // own centered style instead of a bubble. + rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) }, + ) + } } Spacer(modifier = DoubleVertSpacer) @@ -180,10 +197,17 @@ fun MarmotGroupChatView( // stays readable either way, which is the point of a gate that is not // a terminal state. val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle() + val isOutOfSync by chatroom.isOutOfSync.collectAsStateWithLifecycle() + val awaitingReinvite by chatroom.awaitingReinvite.collectAsStateWithLifecycle() val gate = outboundGate - if (gate != null) { + if (awaitingReinvite) { + MarmotGroupNoticeRow(stringRes(Res.string.marmot_awaiting_reinvite)) + } else if (gate != null) { MarmotGroupClosedComposer(gate) } else { + if (isOutOfSync) { + MarmotOutOfSyncBanner(nostrGroupId, accountViewModel) + } MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, newMessageModel = newMessageModel, @@ -235,6 +259,10 @@ fun MarmotGroupMessageComposer( } } + newMessageModel.editingMessage.value?.let { + EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() }) + } + Column(modifier = EditFieldModifier) { newMessageModel.userSuggestions?.let { ShowUserSuggestionList( @@ -378,6 +406,49 @@ private fun MarmotGroupFileUploadDialog( ) } +/** + * This device has fallen off the group's epoch chain (MarmotDesyncDetector): nothing the + * other members send decrypts here, and it will not heal on its own. Offers the one way + * back that exists, dropping the local copy so an admin can add this member again. + */ +@Composable +private fun MarmotOutOfSyncBanner( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, +) { + var confirming by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { + Text( + text = stringRes(Res.string.marmot_out_of_sync_body), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { confirming = true }, modifier = Modifier.align(Alignment.End)) { + Text(stringRes(Res.string.marmot_out_of_sync_reset)) + } + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_title)) }, + text = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + confirming = false + accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId) + }, + ) { Text(stringRes(Res.string.marmot_out_of_sync_reset)) } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } +} + /** * Stands in for the composer when an outbound gate is up. * @@ -394,6 +465,11 @@ private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) { LocalOutboundGate.LEAVING -> stringRes(Res.string.marmot_group_composer_leaving) LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed) } + MarmotGroupNoticeRow(message) +} + +@Composable +private fun MarmotGroupNoticeRow(message: String) { Row( modifier = EditFieldModifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 7321b36d9f..a0779f5df5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -102,7 +102,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_privilege import com.vitorpamplona.amethyst.commons.resources.marmot_grant_admin_title import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanded_toast import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanding_toast -import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name import com.vitorpamplona.amethyst.commons.resources.marmot_group_info_title import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group @@ -272,7 +271,13 @@ fun MarmotGroupInfoScreen( Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - text = displayName ?: stringRes(Res.string.marmot_group_fallback_name, nostrGroupId.take(8)), + text = + marmotGroupTitle( + displayName, + remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }, + nostrGroupId, + accountViewModel, + ), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index ec72529de9..7adda762bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -59,7 +59,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.chats.ui.ChatUnreadBadge import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.navigation.Route @@ -73,11 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc -import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text -import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests @@ -90,14 +84,10 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @@ -297,7 +287,7 @@ fun MarmotGroupListItem( ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() - val memberPubkeys = remember(members) { members.map { it.pubkey } } + val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } val newestMessage = chatroom.newestMessage val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle() @@ -307,53 +297,7 @@ fun MarmotGroupListItem( // to ~100 entries, so counting per recomposition is cheap. val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime } - // A preview has to survive the messages that carry no text: a system row - // keeps its state in tags and MIP-04 media keeps its in imeta, so both used - // to render as a blank second line under the group name. - val myPubKey = accountViewModel.account.signer.pubKey - val previewEvent = newestMessage?.event - val previewBody = - when { - newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet) - previewEvent == null -> stringRes(Res.string.marmot_preview_no_text) - previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated) - // Text first: an attachment usually carries a caption, and showing - // "Attachment" over the words the sender actually wrote would be a - // step back from the raw `content` this replaced. - previewEvent.content.isNotBlank() -> previewEvent.content - hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media) - else -> stringRes(Res.string.marmot_preview_no_text) - } - // Only a genuinely known name earns the prefix: `bestName` returns null - // without metadata, and "a1b2c3d4: hi" is noise, not attribution. - // - // Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a - // plain StateFlow.value read, so a kind:0 arriving after the row composed - // would never reach it. - // - // Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only - // subscribes for a sender the cache already knows, and a sender it does not - // simply goes unprefixed. Creating a User per unknown sender would put a - // metadata REQ behind every row of a list that is mostly strangers' names - // the reader never asked for — a preview line is not worth that. - val senderUser = - previewEvent - ?.pubKey - ?.takeIf { it != myPubKey } - ?.let { LocalCache.getUserIfExists(it) } - val senderName = - if (senderUser != null) { - observeUserInfo(senderUser, accountViewModel).value?.info?.bestName() - } else { - null - } - val previewText = - // A system caption already names its actor, so prefixing one would say it twice. - if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) { - stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody) - } else { - previewBody - } + val previewText = marmotGroupPreviewText(newestMessage, accountViewModel) Row( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt new file mode 100644 index 0000000000..4130dc30d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupPreview.kt @@ -0,0 +1,140 @@ +/* + * 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.marmotGroup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name +import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text +import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.observeChatEdit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The second line of a Marmot group's row: the newest message, prefixed with its + * sender's name. Shared by the Marmot group list and the Messages tab, which had + * drifted: the Messages tab required an author on the note and fell back to + * "No messages yet" for anything else, including a group whose newest entry was + * a system row or an attachment. + */ +@Composable +fun marmotGroupPreviewText( + newestMessage: Note?, + accountViewModel: AccountViewModel, +): String { + // A preview has to survive the messages that carry no text: a system row + // keeps its state in tags and MIP-04 media keeps its in imeta, so both used + // to render as a blank second line under the group name. + val myPubKey = accountViewModel.account.signer.pubKey + val previewEvent = newestMessage?.event + // An edited last message previews its new text, as its bubble does. Observed rather than + // read once so an edit arriving while the list is open updates the row. + val editedContent = newestMessage?.let { observeChatEdit(it) }?.event?.content + val shownText = editedContent ?: previewEvent?.content.orEmpty() + val previewBody = + when { + newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet) + previewEvent == null -> stringRes(Res.string.marmot_preview_no_text) + previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated) + // Text first: an attachment usually carries a caption, and showing + // "Attachment" over the words the sender actually wrote would be a + // step back from the raw `content` this replaced. + shownText.isNotBlank() -> shownText + hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media) + else -> stringRes(Res.string.marmot_preview_no_text) + } + // Only a genuinely known name earns the prefix: `bestName` returns null + // without metadata, and "a1b2c3d4: hi" is noise, not attribution. + // + // Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a + // plain StateFlow.value read, so a kind:0 arriving after the row composed + // would never reach it. + // + // Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only + // subscribes for a sender the cache already knows, and a sender it does not + // simply goes unprefixed. Creating a User per unknown sender would put a + // metadata REQ behind every row of a list that is mostly strangers' names + // the reader never asked for — a preview line is not worth that. + val senderUser = + previewEvent + ?.pubKey + ?.takeIf { it != myPubKey } + ?.let { LocalCache.getUserIfExists(it) } + val senderName = + if (senderUser != null) { + observeUserInfo(senderUser, accountViewModel).value?.info?.bestName() + } else { + null + } + val previewText = + // A system caption already names its actor, so prefixing one would say it twice. + if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) { + stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody) + } else { + previewBody + } + + return previewText +} + +/** + * The members a group's row or header should picture and name: everyone but us, like a + * NIP-17 room. A group of just us falls back to us, so a fresh group still has a face. + */ +fun marmotOtherMembers( + members: List, + myPubKey: HexKey, +): List { + val all = members.map { it.pubkey }.distinct() + return all.filter { it != myPubKey }.ifEmpty { all } +} + +/** + * A group's title: its name, else the other members' names. White Noise creates 1:1 + * chats without a name, and "Group c4f0426f…" told the reader nothing about who was + * on the other side. + */ +@Composable +fun marmotGroupTitle( + displayName: String?, + otherMembers: List, + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, +): String { + if (!displayName.isNullOrBlank()) return displayName + if (otherMembers.isEmpty()) return stringRes(Res.string.marmot_group_fallback_name, nostrGroupId.take(8)) + val names = otherMembers.take(4).map { key(it) { observeUserNameByHex(it, accountViewModel) } } + return names.joinToString(", ") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt index fd5804fc87..ac02ee25e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotNewMessageViewModel.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.onUiThread @@ -59,6 +60,9 @@ open class MarmotNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(null) + // My own message being replaced; the next send publishes a kind:1009 edit of it. + val editingMessage = mutableStateOf(null) + var uploadState by mutableStateOf(null) var userSuggestions: UserSuggestionState? = null @@ -83,11 +87,28 @@ open class MarmotNewMessageViewModel : ViewModel() { this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) this.message.clearText() this.replyTo.value = null + this.editingMessage.value = null } } fun reply(note: Note) { replyTo.value = note + // Leaving edit mode drops the edited message's prefilled text too; kept, Send would + // post it again as a new reply. + if (editingMessage.value != null) cancelEdit() + } + + /** Enter edit mode for my own [note], prefilled with the text it currently shows. */ + fun editMarmotMessage(note: Note) { + replyTo.value = null + editingMessage.value = note + val current = note.latestMarmotEdit()?.event?.content ?: note.event?.content ?: "" + message.setTextAndPlaceCursorAtEnd(current) + } + + fun cancelEdit() { + editingMessage.value = null + message.clearText() } fun clearReply() { @@ -137,6 +158,15 @@ open class MarmotNewMessageViewModel : ViewModel() { val text = message.text.toString().trim() if (text.isEmpty()) return + val editing = editingMessage.value + if (editing != null) { + accountViewModel.sendMarmotGroupMessageEdit(groupId, editing, text) + editingMessage.value = null + onUiThread { message.clearText() } + userSuggestions?.reset() + return + } + // Capture id+pubKey snapshot before suspending so a slow send // doesn't race a user-cleared reply state. val parentEvent = replyTo.value?.event diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 81f5853241..47838e144b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -65,9 +65,7 @@ import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back -import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only -import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one @@ -108,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch 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.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress @@ -455,30 +454,7 @@ private fun ConcordMessageComposer( // Edit mode: a banner reminding the user the next send replaces this message (a kind-1010 // edit on the channel plane), with an X to abandon the edit and clear the field. newMessageModel.editingMessage.value?.let { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SymbolIcon( - symbol = MaterialSymbols.Edit, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(Res.string.concord_editing_banner), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f).padding(start = 8.dp), - ) - IconButton(onClick = { newMessageModel.cancelEdit() }) { - SymbolIcon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(Res.string.cancel), - modifier = Modifier.size(16.dp), - ) - } - } + EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() }) } Column(modifier = EditFieldModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index ecd6dd4667..0361980ecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -133,7 +133,9 @@ open class ConcordNewMessageViewModel : ViewModel() { fun reply(note: Note) { replyTo.value = note replyMode.value = ReplyMode.INLINE - editingMessage.value = null + // Leaving edit mode drops the edited message's prefilled text too; kept, Send would + // post it again as a new reply. + if (editingMessage.value != null) cancelEdit() } /** Enter edit mode for my own [note]: prefills the field with its current text; sending publishes a kind-1010 edit. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 514b461e3f..d782720627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.resources.geohash_chat import com.vitorpamplona.amethyst.commons.resources.leave import com.vitorpamplona.amethyst.commons.resources.loading_feed import com.vitorpamplona.amethyst.commons.resources.marmot_group -import com.vitorpamplona.amethyst.commons.resources.marmot_group_no_messages_yet import com.vitorpamplona.amethyst.commons.resources.mute_notifications import com.vitorpamplona.amethyst.commons.resources.muted_chat_content_description import com.vitorpamplona.amethyst.commons.resources.pin_conversation @@ -144,6 +143,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription @@ -515,13 +517,34 @@ private fun MarmotGroupRoomCompose( val relays by chatroom.relays.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() - val author = lastMessage.author - val noteEvent = lastMessage.event - val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" + val members by chatroom.members.collectAsStateWithLifecycle() + val otherMembers = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) } + val groupName = marmotGroupTitle(displayName, otherMembers, chatroom.nostrGroupId, accountViewModel) + + // The row is handed the group's placeholder note when it has no messages. + val lastContent = marmotGroupPreviewText(lastMessage.takeIf { it.event != null }, accountViewModel) + + val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle() + + val hasGroupFace = avatarUrl != null || image != null || !displayName.isNullOrBlank() + if (!hasGroupFace && otherMembers.isNotEmpty()) { + // An unnamed group without an avatar (White Noise's 1:1 chats) is about its people: + // show them, as a NIP-17 room does, instead of a relay icon. + ChannelName( + channelPicture = { NonClickableUserPictures(userHexList = otherMembers, size = Size55dp, accountViewModel = accountViewModel) }, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, MaterialSymbols.Lock, Res.string.marmot_group, modifier) }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, + onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) }, + ) + return + } // Prefer the group's own avatar — the plain https link first, then the // encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon - // of one of the group's relays (fetched on a cache miss). + // of one of the group's relays (fetched on a cache miss). Resolved only here: a row that + // shows its members' faces above never draws it, and the relay icon is a NIP-11 fetch. val channelPicture = if (avatarUrl != null || image != null) { rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) @@ -529,16 +552,6 @@ private fun MarmotGroupRoomCompose( loadMarmotRelayIcon(relays) } - val lastContent = - if (author != null && noteEvent != null) { - val authorName by observeUserName(author, accountViewModel) - "$authorName: ${noteEvent.content.take(200)}" - } else { - stringRes(Res.string.marmot_group_no_messages_yet) - } - - val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle() - ChannelName( channelIdHex = chatroom.nostrGroupId, channelPicture = channelPicture, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 2c6327533a..ea55a4ee76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -78,7 +78,10 @@ class ChatFileUploadState( } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt new file mode 100644 index 0000000000..a000abf5ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/EditingMessageBanner.kt @@ -0,0 +1,71 @@ +/* + * 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.utils + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Shown above a chat composer in edit mode: the next send replaces one of my messages + * instead of posting a new one. The X abandons the edit and clears the field. + */ +@Composable +fun EditingMessageBanner(onCancel: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(Res.string.concord_editing_banner), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + IconButton(onClick = onCancel) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(Res.string.cancel), + modifier = Modifier.size(16.dp), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 4b10bc1c3c..de2ed7b234 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -671,7 +671,10 @@ class LongFormPostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 9b588113cd..3d9a0f574d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -514,7 +514,10 @@ open class NewProductViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index e4733e4694..fe72a2233d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1753,7 +1753,10 @@ open class ShortNotePostViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 5468d27777..763670e9df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -401,11 +401,16 @@ private fun EncryptedKeyCard( var encrypted by remember { mutableStateOf(null) } var showQr by remember { mutableStateOf(false) } - // Drop the result while in the background. A password not yet used is kept so switching to - // a password manager to fetch it doesn't wipe the fields; a used one is cleared on success. + // Drop the result and the typed password while in the background, so neither sits in memory + // (or on the recents thumbnail, with the password shown) behind another app. The cost: leaving + // to copy a password out of a password manager app clears what was typed; autofill, which + // fills in place without stopping this activity, is unaffected. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { encrypted = null showQr = false + password = "" + repeated = "" + showPassword = false } // A typo in the password makes the backup permanently useless, so it must be typed twice. @@ -415,9 +420,11 @@ private fun EncryptedKeyCard( fun encrypt() { if (!canEncrypt) return + // Read before the gate: a device-credential prompt is its own activity, so it stops this + // one and the ON_STOP above clears the fields before the unlock callback runs. + val currentPassword = password gate.withAccess { val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess - val currentPassword = password working = true // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 32ae7d4e20..b33f4b1daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -557,7 +557,10 @@ class NewPublicMessageViewModel : } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { - this.multiOrchestrator?.remove(selected) + val orchestrator = multiOrchestrator ?: return + orchestrator.remove(selected) + // An empty orchestrator still renders the gallery, which reads its first item. + if (orchestrator.size() == 0) multiOrchestrator = null } override fun onMessageChanged() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt index 1cf4571916..d9c55f6e90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayStatusRow.kt @@ -185,9 +185,9 @@ fun RelayStatusRow( stringRes(Res.string.errors), modifier = Size15Modifier, tint = - if (successRate < 0.1) { + if (successRate < 10) { MaterialTheme.colorScheme.redColorOnSecondSurface - } else if (successRate < 0.60) { + } else if (successRate < 60) { MaterialTheme.colorScheme.warningColor } else { MaterialTheme.colorScheme.allGoodColor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index b35647c0e2..c860a5841b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -274,9 +274,14 @@ private fun PasswordField(loginViewModel: LoginViewModel) { onGo = loginViewModel::login, ) - LaunchedEffect(Unit) { - delay(300) - passwordFocusRequester.requestFocus() + // Only once the key is complete: a pasted ncryptsec jumps straight to the password, + // one being typed keeps its focus until the last character. + val keyComplete = loginViewModel.isCompleteNcryptsec + LaunchedEffect(keyComplete) { + if (keyComplete) { + delay(300) + passwordFocusRequester.requestFocus() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index fd91a9fdec..81a2326351 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.commons.resources.sign_request_rejected_descri import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @Stable class LoginViewModel : ViewModel() { @@ -62,6 +63,20 @@ class LoginViewModel : ViewModel() { Bech32Transcription.normalize(key.text).startsWith("ncryptsec1") } + /** + * The key field holds a whole, checksummed ncryptsec. [needsPassword] turns true at the + * first "ncryptsec1", which is what shows the password field; moving focus there has to + * wait for this, or a key typed by hand loses focus after its tenth character. + */ + val isCompleteNcryptsec by derivedStateOf { + needsPassword && + try { + Bech32Transcription.normalize(key.text).bechToBytes("ncryptsec").size == NCRYPTSEC_PAYLOAD_SIZE + } catch (e: Exception) { + false + } + } + var isFirstLogin by mutableStateOf(false) fun init(accountSessionManager: AccountSessionManager) { @@ -192,4 +207,9 @@ class LoginViewModel : ViewModel() { } } } + + companion object { + // version + log_n + salt(16) + nonce(24) + key security + ciphertext(48), per NIP-49. + private const val NCRYPTSEC_PAYLOAD_SIZE = 91 + } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 8c66e70686..3b56df46df 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -113,7 +113,14 @@ object MessageCommands { // A deletion is not its own row either. The retracted body is // blanked rather than the row dropped, so a harness (or a reader // paging back) can tell "retracted" from "never arrived". - val deleted = ctx.marmot.deletedIds(parsed) + val deleted = + ctx.marmot.deletedIds( + parsed, + ctx.marmot + .groupView(gid) + ?.adminPubkeys + ?.toSet() ?: emptySet(), + ) // Pinned at persist time from the retention of the epoch that // DELIVERED each message, so it is the message's own expiry and not // a recomputation against whatever the group's setting is now. diff --git a/cli/tests/README.md b/cli/tests/README.md index c91ed90712..bf8151a34c 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -170,6 +170,12 @@ the Marmot protocol, via its `wn` / `wnd` binaries (the `wn-cli` package). Every test records a pass/fail/skip result into a tab-separated log, and the summary is printed at the end of the run. +The MDK checkout is pinned (`MDK_PIN` in `marmot/setup.sh`) to the commit the +shipping White Noise apps embed, read from their `MARMOT_VERSION` lockfiles; +when Android and iOS disagree the newer one wins. As of 2026-09-27 that is +`03b1809e` (White Noise Android; iOS is on the 0.10.4 release `fcc85edd`, an +ancestor). Override with `MDK_PIN=` to test another MDK. + > These harnesses previously targeted `marmot-protocol/whitenoise-rs`, which was > archived on 2026-08-05 pinned to `mdk-core 0.8.0`. Testing against it meant > testing against a frozen MIP-era client. The reference moved into `mdk`, and @@ -214,6 +220,22 @@ would make strict-mode DM sends spuriously fail. `127.0.0.2` is still pure loopback and isn't matched by that filter. Override with `--host 127.0.0.5` etc. if `127.0.0.2` is taken. +**macOS:** Linux answers every `127.0.0.0/8` address on `lo`; macOS only +answers `127.0.0.1` until you add an alias, once per boot: + +```bash +sudo ifconfig lo0 alias 127.0.0.2 up +``` + +The harnesses check for it and stop with that command instead of failing +later with "Can't assign requested address". Two more macOS traps are handled +for you: `wnd` sockets are moved to a short `/tmp/wnd.*` directory when the +checkout path is too long for `sun_path` ("path must be shorter than +SUN_LEN"), and `amy` is rebuilt incrementally on every run (a stale +`cli/build/install/amy` from another branch produces misleading publish +failures), unless you pass `--no-build`. A run killed mid-way can leave +`amy serve` holding the relay port; `pkill -f "amy.*serve"` frees it. + **Note:** dm-05 validates the kind:15 wire format via reference mode (caller supplies the URL + AES-GCM key/nonce). The upload-mode variant (`dm send-file --file PATH --server URL`) needs a local Blossom server @@ -240,7 +262,7 @@ On the Android side: ## Quick start ```bash -cd tools/marmot-interop +cd cli/tests/marmot ./marmot-interop.sh ``` diff --git a/cli/tests/headless/helpers.sh b/cli/tests/headless/helpers.sh index ea45199223..124f13e5c2 100644 --- a/cli/tests/headless/helpers.sh +++ b/cli/tests/headless/helpers.sh @@ -66,6 +66,35 @@ assert_eq() { return 1 } +# macOS caps a unix socket path at 104 bytes (sun_path) and wnd refuses a longer +# one ("path must be shorter than SUN_LEN"). wnd binds first inside a staging +# dir next to the final path (`.sock../`, ~30 bytes more), so a +# checkout under an ordinary home dir already crosses it: ~85 bytes failed. Move +# such a socket into a short private temp dir (0700, since wnd also refuses a +# socket dir others can read). +# +# The dir is derived from the long path, not random: the same checkout gets the same +# socket every run, so `start_daemon` still finds a wnd a killed run left behind +# instead of starting a second one on the same data, and runs stop piling up dirs. +short_socket_path() { + local path="$1" + if [[ ${#path} -lt 70 ]]; then printf '%s' "$path"; return; fi + # Resolved, not /tmp itself: on macOS /tmp is a symlink to /private/tmp and wnd + # refuses a socket path through an alias ("untrusted directory alias"). + local tmp dir + tmp="$(cd /tmp && pwd -P)" + dir="$tmp/wnd-$(id -u)-$(printf '%s' "$path" | cksum | cut -d' ' -f1)" + if ! mkdir -m 700 "$dir" 2>/dev/null; then + # Reuse only a dir that is ours and private: /tmp is shared, and a socket in a + # dir someone else controls could be swapped under wnd. + if [[ ! -d "$dir" || -L "$dir" || ! -O "$dir" ]]; then + dir="$(mktemp -d "$tmp/wnd.XXXXXX")" + fi + chmod 700 "$dir" + fi + printf '%s/%s.sock' "$dir" "$(basename "$(dirname "$path")")" +} + # --- embedded relay (amy serve → geode) -------------------------------------- # Every relay-backed harness talks to ONE loopback relay, and that relay is # `amy serve` — i.e. geode, the relay this repo ships — booted from the amy @@ -97,6 +126,15 @@ start_local_relay() { local bind="${RELAY_BIND:-$RELAY_HOST}" mkdir -p "$relay_home" "$RELAY_DATA/logs" + # Linux answers all of 127.0.0.0/8 on lo; macOS only 127.0.0.1 until an alias + # is added, and binding to anything else fails with a bare "Can't assign + # requested address" deep in the relay log. + if [[ "$(uname -s)" == "Darwin" && "$bind" == 127.* && "$bind" != "127.0.0.1" ]] \ + && ! ifconfig lo0 2>/dev/null | grep -q "inet $bind "; then + fail_msg "$bind is not on lo0. On macOS add it once per boot: sudo ifconfig lo0 alias $bind up" + exit 1 + fi + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; } # Abort early if something else is already bound to the port — failing diff --git a/cli/tests/lib.sh b/cli/tests/lib.sh index 2bb3f00627..c0f55b5f70 100644 --- a/cli/tests/lib.sh +++ b/cli/tests/lib.sh @@ -199,6 +199,29 @@ jq_list() { ' 2>/dev/null || true } +# Whether 's wn lists in the group's . Three answers, because a +# query that fails is not evidence of absence: piped straight into `jq -e select`, a dead +# daemon or an `ok:false` reply read exactly like "not listed" and passed removal tests. +# 0 listed · 1 wn answered and is not listed · 2 no usable answer +wn_lists() { + local who="$1" gid="$2" list="$3" hex="$4" out + out=$("wn_$who" --json groups members "$gid" 2>/dev/null) || return 2 + printf '%s' "$out" | jq -e '.ok == true' >/dev/null 2>&1 || return 2 + if printf '%s' "$out" | jq_list "$list" | jq -e --arg p "$hex" \ + 'select((.member_id // .admin_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +# Whether 's wn reports that it was itself removed from the group: MDK's +# `groups show` carries `self_membership: "removed"` once the removal commit applied. +wn_self_removed() { + local who="$1" gid="$2" + "wn_$who" --json groups show "$gid" 2>/dev/null \ + | jq -e '.ok == true and .result.group.self_membership == "removed"' >/dev/null 2>&1 +} + # npub or hex pubkey of one member/admin entry. MDK names the field per # collection: members carry `member_id`, admins carry `admin_id`. jq_member_ids() { diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 1218997077..37b233460e 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -140,6 +140,8 @@ source "$TESTS_DIR/lib.sh" source "$SCRIPT_DIR/setup.sh" # shellcheck source=../headless/helpers.sh source "$TESTS_DIR/headless/helpers.sh" +B_SOCKET="$(short_socket_path "$B_SOCKET")" +C_SOCKET="$(short_socket_path "$C_SOCKET")" # shellcheck source=tests-create.sh source "$SCRIPT_DIR/tests-create.sh" # shellcheck source=tests-manage.sh @@ -212,6 +214,9 @@ ALL_TESTS=( test_29_disband_amy_to_wn test_30_wn_commit_after_app_data_update test_31_reaction_materializes_on_wn + test_32_amy_message_after_wn_commit + test_33_wn_leaves_amy_admin_group + test_34_amy_removes_last_other_member ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/marmot-interop.sh b/cli/tests/marmot/marmot-interop.sh index d39481c7c3..8003f0fd4f 100755 --- a/cli/tests/marmot/marmot-interop.sh +++ b/cli/tests/marmot/marmot-interop.sh @@ -111,6 +111,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs" source "$TESTS_DIR/lib.sh" # shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve) source "$TESTS_DIR/headless/helpers.sh" +B_SOCKET="$(short_socket_path "$B_SOCKET")" +C_SOCKET="$(short_socket_path "$C_SOCKET")" # --- preflight --------------------------------------------------------------- preflight() { diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 3b1ceabafb..163d0a0f89 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -17,26 +17,35 @@ preflight() { info "$cmd: $(command -v "$cmd")" done - # Build `amy` via gradle if missing. + # Build `amy` via gradle. # # jitpack.io and dl.google.com both return transient 503s on a non-trivial # fraction of cold-cache fetches, and Gradle disables the entire repository # for the rest of the build the moment a single 503 lands — so one bad # roll aborts the whole harness. Retry a few times; each attempt resumes # from Gradle's cache so only the still-missing artifacts get re-fetched. - if [[ ! -x "$AMY_BIN" ]]; then - if [[ "$NO_BUILD" -eq 1 ]]; then - fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 - fi - local attempt max_attempts=4 + # + # Build even when the binary exists: an old install/amy from another branch + # tests yesterday's code and fails in misleading ways (a stale amy produced + # "UNIQUE constraint" publish rejections that looked like relay bugs). Gradle + # makes an up-to-date install a no-op. --no-build keeps whatever is there. + if [[ "$NO_BUILD" -eq 1 ]]; then + [[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1; } + warn "--no-build: using the existing $AMY_BIN, which may be older than this checkout" + else + local attempt max_attempts=4 built=0 for attempt in $(seq 1 $max_attempts); do step "building :cli:installDist (attempt $attempt/$max_attempts)" - if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ - && [[ -x "$AMY_BIN" ]]; then - break + ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" + # gradle's status, not tee's: a failed build must not fall through to the old binary. + if [[ "${PIPESTATUS[0]}" -eq 0 && -x "$AMY_BIN" ]]; then + built=1; break fi [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" done + # The binary may still exist from an earlier build; running the suite on it would test + # code this checkout no longer has. + [[ "$built" -eq 1 ]] || { fail_msg "amy build failed after $max_attempts attempts"; exit 1; } fi [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } info "amy: $AMY_BIN" @@ -68,17 +77,18 @@ preflight() { # what users are running", which is the question the harness exists to answer. # # THE TWO APPS NO LONGER AGREE, and the rule for that is: take the newer. - # As of 2026-09-10 android is on 0.9.21 (`fdd398a8`) and ios is still on - # 0.9.20 (`2f44f6b6`) — android syncs its bindings on its own cadence and got + # As of 2026-09-27 android is on a master snapshot after 0.10.4 + # (`03b1809e`, 2026-09-26) and ios on the 0.10.4 release (`fcc85edd`), which + # is an ancestor of it — android syncs its bindings on its own cadence and got # there first. The newer one is where new validation lands, so it is where - # drift shows up first; a client that satisfies 0.9.21 satisfies 0.9.20, - # since every 0.9.20 rule is still in 0.9.21. Pinning to the laggard would - # test the subset and call it coverage. + # drift shows up first; a client that satisfies the newer MDK satisfies the + # older one. Pinning to the laggard would test the subset and call it + # coverage. # # Bump it deliberately, by reading those lockfiles again — not by drifting. # If they agree again, that is the value; if they disagree, take the newer # and say so here. - MDK_PIN="${MDK_PIN:-fdd398a80f1626f1713787cebe416f7890b5b204}" + MDK_PIN="${MDK_PIN:-03b1809e6387f2d95e566a6a2d2f1212bec4d41b}" if [[ "$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null)" != "$MDK_PIN" ]]; then if [[ "$NO_BUILD" -eq 1 ]]; then info "mdk is not at the pinned $MDK_PIN and --no-build set — testing whatever is checked out" diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 2d6ea3f396..f201392fa6 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -27,10 +27,7 @@ test_06_member_removal() { # C should no longer see the group on its own member view. local deadline=$(( $(date +%s) + 120 )) removed=0 while [[ $(date +%s) -lt $deadline ]]; do - if ! wn_c --json groups members "$mls_gid" 2>/dev/null \ - | jq_list members | jq -e --arg p "$C_HEX" \ - 'select((.member_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + if wn_self_removed c "$mls_gid"; then removed=1; break fi sleep 3 @@ -180,10 +177,9 @@ test_11_leave_group() { local deadline=$(( $(date +%s) + 120 )) gone=0 while [[ $(date +%s) -lt $deadline ]]; do - if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ - | jq_list admins | jq -e --arg p "$A_HEX" \ - 'select((.admin_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + local rc=0 + wn_lists b "$mls_gid" admins "$A_HEX" || rc=$? + if [[ "$rc" -eq 1 ]]; then gone=1; break fi sleep 3 @@ -222,10 +218,9 @@ test_17_group_image_commit() { # Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed # A) — this test only makes sense while A can still commit to the group. - if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ - | jq_list members | jq -e --arg p "$A_HEX" \ - 'select((.member_id // .pubkey // .public_key) == $p)' \ - >/dev/null 2>&1; then + local listed=0 + wn_lists b "$mls_gid" members "$A_HEX" || listed=$? + if [[ "$listed" -eq 1 ]]; then record_result "$id" skip "A not in GROUP_02"; return fi @@ -337,3 +332,104 @@ test_31_reaction_materializes_on_wn() { printf '%s\n' "$tl" >> "$LOG_FILE" record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)" } + +# The other direction of test 30. After wn commits (a rename carries an +# UpdatePath), both sides reached the same epoch and wn's messages kept +# decrypting on amy, yet wn never showed another amy message. +test_32_amy_message_after_wn_commit() { + banner "Test 32 — amy's message after wn's commit reaches wn" + local id="32 amy after wn commit" + + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-32") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy promote failed"; return; } + sleep 5 + wn_b groups rename "$mls_gid" "Interop-32-by-wn" >/dev/null 2>&1 || true + amy_json marmot await rename "$gid" --name "Interop-32-by-wn" --timeout 120 >/dev/null || { record_result "$id" fail "amy did not apply wn's rename"; return; } + + amy_json marmot message send "$gid" "32 from amy after wn commit" >/dev/null || { record_result "$id" fail "amy send failed"; return; } + if wait_for_message B "$mls_gid" "32 from amy after wn commit" 90; then + record_result "$id" pass + else + record_result "$id" fail "wn never received amy's message sent after wn's commit" + fi +} + +test_33_wn_leaves_amy_admin_group() { + banner "Test 33 — wn leaves a group amy administers; amy commits the departure" + local id="33 wn leaves amy's group" + + # Leaving is a SelfRemove proposal that only an admin can commit. Test 15 + # covers a wn admin committing it; here amy is the only admin, so the + # departure takes effect only if amy commits it during sync. + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-33") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b groups leave "$mls_gid" >/dev/null 2>&1 || { record_result "$id" fail "wn leave failed"; return; } + + local deadline=$(( $(date +%s) + 120 )) show b_still=1 + while [[ $(date +%s) -lt $deadline ]]; do + show=$(amy_json marmot group show "$gid" 2>/dev/null) || { sleep 3; continue; } + b_still=$(printf '%s' "$show" | jq --arg p "$B_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length') + [[ "$b_still" == "0" ]] && break + sleep 3 + done + if [[ "$b_still" == "0" ]]; then + record_result "$id" pass + else + record_result "$id" fail "amy never committed wn's SelfRemove; wn is still in the tree" + fi +} + +test_34_amy_removes_last_other_member() { + banner "Test 34 — amy removes the only other member; wn processes its own removal" + local id="34 amy removes wn from a 2-member group" + + # Test 06 removes one of three members. Removing the only other member leaves + # the committer alone in the tree, which is the shape a device removal hit: + # White Noise never applied it and kept showing itself as a member. + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-34") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b messages send "$mls_gid" "34 before removal" >/dev/null 2>&1 || true + amy_json marmot await message "$gid" --match "34 before removal" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got wn's message"; return; } + + amy_json marmot group remove "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy remove failed"; return; } + + local deadline=$(( $(date +%s) + 120 )) gone=0 view + while [[ $(date +%s) -lt $deadline ]]; do + wn_b sync >/dev/null 2>&1 || true + # A positive signal: wn marks its own copy removed once it applied the commit. Reading + # "B is not in the member list" instead passed whenever the query itself failed. + view=$(wn_b_json groups show "$mls_gid" 2>/dev/null || true) + if wn_self_removed b "$mls_gid"; then + gone=1; break + fi + sleep 3 + done + printf '%s' "$view" >"$STATE_DIR/test34-wn-view.json" + if [[ "$gone" -eq 1 ]]; then + record_result "$id" pass + else + record_result "$id" fail "wn still lists itself as a member after amy removed it" + fi +} diff --git a/cli/tests/marmot/tests-media.sh b/cli/tests/marmot/tests-media.sh index 1779cc4ee4..ad5e9b92c9 100644 --- a/cli/tests/marmot/tests-media.sh +++ b/cli/tests/marmot/tests-media.sh @@ -439,8 +439,15 @@ test_27_deletion_wn_to_amy() { record_result "$id" fail "amy has no event id for wn's message"; return fi - if ! wn_b messages delete "$mls_gid" "$target" >/dev/null 2>&1; then + # Keep wn's answer: "the command ran" and "the tombstone reached a relay" are + # different, and only the second one can reach amy. + local del + del=$(wn_b --json messages delete "$mls_gid" "$target" 2>>"$LOG_FILE") || { record_result "$id" fail "wn messages delete failed"; return + } + printf 'wn messages delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE" + if ! printf '%s' "$del" | jq -e '(.result.published // 0) > 0' >/dev/null 2>&1; then + record_result "$id" fail "wn did not publish the delete tombstone: $del"; return fi # The row stays, blanked and flagged: "retracted" and "never arrived" are @@ -460,7 +467,12 @@ test_27_deletion_wn_to_amy() { done if [[ "$gone" -ne 1 ]]; then - record_result "$id" fail "amy never marked wn's message deleted"; return + # Tell a lost tombstone from a split group: if the two sides sit on different + # epochs, the delete was sent where amy cannot follow. + local wn_epoch amy_epoch + wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // "?"') + amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // "?"') + record_result "$id" fail "amy never marked wn's message deleted (wn epoch $wn_epoch, amy epoch $amy_epoch)"; return fi if [[ -n "$body" ]]; then record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return @@ -695,6 +707,7 @@ test_29_disband_amy_to_wn() { wn_b sync >/dev/null 2>&1 || true sleep 5 done + wn_b_json groups show "$mls_gid" >"$STATE_DIR/disband29-wn-view.json" 2>&1 || true printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \ "$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-}" >>"$LOG_FILE" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt new file mode 100644 index 0000000000..4806ba5c0f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetector.kt @@ -0,0 +1,103 @@ +/* + * 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.commons.marmot + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Notices when this device has fallen off a group's epoch chain: the other members + * keep talking and none of it decrypts here. + * + * A fork like that does not heal on its own. The members who moved on hold no copy of + * the epoch this device is stuck at, and this device cannot apply their commits, so + * every message they send fails with "decrypts on no canonical epoch". Groups broken + * this way before the own-commit echo fix never recovered, and nothing told the user. + * + * The same error is also routine: an event from BEFORE this device joined, or older + * than the retained epochs, fails exactly like that. So only failures NEWER than the + * last event this group decrypted here count, a single burst does not trip it (they + * must span [minSpanSec] of sender time), and one successful decrypt clears it. A + * group with no baseline (never decrypted anything here and not seeded) is never + * flagged: there is nothing to be behind. + */ +class MarmotDesyncDetector( + private val threshold: Int = 3, + private val minSpanSec: Long = 60, +) { + private class Tracker { + var lastSuccessAt: Long = 0 + val failures = mutableMapOf() + var desynced = false + } + + private val lock = KmpLock() + private val groups = mutableMapOf() + + /** Baseline for a group restored or joined with no decrypt yet this session. */ + fun seed( + groupId: HexKey, + createdAt: Long, + ) = lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + } + + /** Something at [createdAt] decrypted: the group is on the chain. Returns true if that cleared a desync. */ + fun onDecrypted( + groupId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups.getOrPut(groupId) { Tracker() } + if (createdAt > t.lastSuccessAt) t.lastSuccessAt = createdAt + t.failures.clear() + val was = t.desynced + t.desynced = false + was + } + + /** An app message that decrypts on no epoch here. Returns true when this call newly flags the group. */ + fun onUndecryptable( + groupId: HexKey, + eventId: HexKey, + createdAt: Long, + ): Boolean = + lock.withLock { + val t = groups[groupId] ?: return@withLock false + if (t.lastSuccessAt == 0L || createdAt <= t.lastSuccessAt || t.desynced) return@withLock false + t.failures[eventId] = createdAt + val span = t.failures.values.max() - t.failures.values.min() + if (t.failures.size >= threshold && span >= minSpanSec) { + t.desynced = true + true + } else { + false + } + } + + fun isDesynced(groupId: HexKey): Boolean = lock.withLock { groups[groupId]?.desynced == true } + + fun forget(groupId: HexKey) { + lock.withLock { groups.remove(groupId) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 9a30894f69..2fa48e3f6e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore @@ -82,6 +84,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent @@ -177,6 +180,8 @@ class MarmotManager( * Restore all Marmot state from persistent storage. * Call once during Account initialization. */ + private val desync = MarmotDesyncDetector() + suspend fun restoreAll() { Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" } try { @@ -186,8 +191,13 @@ class MarmotManager( // syncWithGroupManager fills in default (since = null) entries, // so even the first filter set sent to relays skips the // already-processed kind:445 backlog. - subscriptionSinceFromStoredMessages(activeIds).forEach { (groupId, since) -> - subscriptionManager.subscribeGroup(groupId, since) + newestStoredMessageTimes(activeIds).forEach { (groupId, newest) -> + // The newest event this group decrypted before the restart is where "behind" + // starts counting (see MarmotDesyncDetector). + desync.seed(groupId, newest) + if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { + subscriptionManager.subscribeGroup(groupId, newest - GROUP_EVENT_REFETCH_OVERLAP_SEC) + } } subscriptionManager.syncWithGroupManager(activeIds) // Seed convergence with each restored state. A commit that arrives @@ -204,7 +214,8 @@ class MarmotManager( keyPackageRotationManager.restoreFromStore() ingestDedupStore?.loadAll()?.let { marks -> terminallyIngestedMutex.withLock { terminallyIngested.addAll(marks) } - Unit + val created = activeIds.filter { createdLocallyMarker(it) in marks } + createdLocallyLock.withLock { createdLocally.addAll(created) } } retryPendingPublishObligations() Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } @@ -221,6 +232,23 @@ class MarmotManager( private val terminallyIngested = mutableSetOf() private val terminallyIngestedMutex = Mutex() + // Groups this device created. Holding leaf 0 is not proof: a commit that removes leaf 0 + // and adds someone in the same epoch places the invitee there, which let a crafted + // invite skip New Requests. So creation leaves a durable marker, stored with the ingest + // markers (hashed, so it cannot collide with an event id) and read back on restore. + private val createdLocally = mutableSetOf() + private val createdLocallyLock = KmpLock() + + private fun createdLocallyMarker(nostrGroupId: HexKey): HexKey = sha256("amethyst:marmot:created-locally:$nostrGroupId".encodeToByteArray()).toHexKey() + + private suspend fun markCreatedLocally(nostrGroupId: HexKey) { + createdLocallyLock.withLock { createdLocally.add(nostrGroupId) } + markTerminallyIngested(createdLocallyMarker(nostrGroupId)) + } + + /** Whether this device created [nostrGroupId]. */ + fun isCreatedLocally(nostrGroupId: HexKey): Boolean = createdLocallyLock.withLock { nostrGroupId in createdLocally } + suspend fun isTerminallyIngested(eventId: HexKey): Boolean = terminallyIngestedMutex.withLock { eventId in terminallyIngested } suspend fun markTerminallyIngested(eventId: HexKey) { @@ -391,7 +419,7 @@ class MarmotManager( * are still fetched; replays inside the window are deduplicated by the * message store and by note identity in the chatroom. */ - private suspend fun subscriptionSinceFromStoredMessages(groupIds: Set): Map { + private suspend fun newestStoredMessageTimes(groupIds: Set): Map { if (messageStore == null) return emptyMap() val result = mutableMapOf() for (groupId in groupIds) { @@ -408,10 +436,7 @@ class MarmotManager( // and a single future-dated message must not push `since` past // the present — that would skip genuinely new events on every // restart until a fresher message arrives. - val newest = minOf(newestStored, TimeUtils.now()) - if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) { - result[groupId] = newest - GROUP_EVENT_REFETCH_OVERLAP_SEC - } + result[groupId] = minOf(newestStored, TimeUtils.now()) } return result } @@ -442,14 +467,17 @@ class MarmotManager( when (result) { is GroupEventResult.ApplicationMessage -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitProcessed -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.ProposalStaged -> { subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) + desync.onDecrypted(result.groupId, groupEvent.createdAt) } is GroupEventResult.CommitPending, @@ -457,13 +485,43 @@ class MarmotManager( is GroupEventResult.UndecryptableOuterLayer, is GroupEventResult.AppMessageOnCandidateBranch, is GroupEventResult.RefusedByLifecycle, - is GroupEventResult.Error, -> {} + + is GroupEventResult.Error -> { + val groupId = result.groupId + if (groupId != null && result.message.startsWith(NO_CANONICAL_EPOCH_ERROR)) { + if (desync.onUndecryptable(groupId, groupEvent.id, groupEvent.createdAt)) { + Log.w("MarmotManager") { "group ${groupId.take(8)}… is out of sync: newer peer messages decrypt on no epoch here" } + } + } + } } return result } + /** Whether the other members of [nostrGroupId] have moved on to epochs this device can't follow. */ + fun isOutOfSync(nostrGroupId: HexKey): Boolean = desync.isDesynced(nostrGroupId) + + /** + * Drop this device's copy of a group it has fallen out of sync with, so it can be + * invited back. + * + * A fork cannot be repaired from this side: the peers hold no copy of our epoch and + * we cannot apply theirs, and an external join needs a GroupInfo nobody publishes. + * What does work is the ordinary invite path. With the MLS state gone, a new Welcome + * is not "already a member" and joins; an admin removes this member and adds it back. + * Nothing is published: a SelfRemove at our stale epoch would decrypt for no one. + * The decrypted history stays on disk, so it is back when the group is. + */ + suspend fun resetOutOfSyncGroup(nostrGroupId: HexKey) { + subscriptionManager.unsubscribeGroup(nostrGroupId) + publishGate.forget(nostrGroupId) + groupManager.removeGroupState(nostrGroupId) + desync.forget(nostrGroupId) + Log.w("MarmotManager") { "reset out-of-sync group ${nostrGroupId.take(8)}…; waiting for a new Welcome" } + } + /** * Process a WelcomeEvent (kind:444) after NIP-59 unwrapping. * Returns the result including whether KeyPackage rotation is needed. @@ -478,6 +536,8 @@ class MarmotManager( val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId) if (result is WelcomeResult.Joined) { + // Joined now: only what is sent from here on has to decrypt. + desync.seed(result.nostrGroupId, TimeUtils.now()) // An authenticated re-join is what clears a departure gate — the // rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it // just as much: a member who left and was invited back holds a gate @@ -743,6 +803,21 @@ class MarmotManager( /** The group's current MLS epoch, which the stream key context binds. */ fun currentEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch + /** + * The kind:1009 edit rumor alone, for a caller that sends it through its own + * show-then-publish path (the app's composer) rather than [buildMessageEdit]. + */ + fun buildMessageEditRumor( + targetEventId: HexKey, + replacement: String, + ): Event { + val template = + eventTemplate(kind = MarmotAppEvent.KIND_EDIT, description = replacement) { + addUnique(arrayOf("e", targetEventId)) + } + return RumorAssembler.assembleRumor(signer.pubKey, template) + } + /** * Build a kind:1009 edit that replaces the text of a prior message. * @@ -762,14 +837,7 @@ class MarmotManager( replacement: String, persistOwn: Boolean = true, ): TextMessageBundle { - val template = - com.vitorpamplona.quartz.nip01Core.signers - .eventTemplate(kind = MarmotAppEvent.KIND_EDIT, description = replacement) { - addUnique(arrayOf("e", targetEventId)) - } - val innerEvent = - com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler - .assembleRumor(signer.pubKey, template) + val innerEvent = buildMessageEditRumor(targetEventId, replacement) val outbound = buildGroupMessage(nostrGroupId, innerEvent) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) @@ -1063,7 +1131,9 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) + markCreatedLocally(nostrGroupId) Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } return nostrGroupId } @@ -1105,7 +1175,9 @@ class MarmotManager( publishGate.satisfyEmptyObligation(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId) + desync.seed(nostrGroupId, TimeUtils.now()) subscriptionManager.subscribeGroup(nostrGroupId) + markCreatedLocally(nostrGroupId) return nostrGroupId } @@ -1119,6 +1191,11 @@ class MarmotManager( val confirmed: Boolean, ) + private val commitPreparationLocks = mutableMapOf() + private val commitPreparationLocksLock = Mutex() + + private suspend fun commitPreparationLock(nostrGroupId: HexKey): Mutex = commitPreparationLocksLock.withLock { commitPreparationLocks.getOrPut(nostrGroupId) { Mutex() } } + /** * Prepare a local commit, publish it, and apply it only if publication was * acknowledged (`protocol-core/publish-lifecycle.md`). @@ -1140,41 +1217,49 @@ class MarmotManager( ignoringGate: LocalOutboundGate? = null, stage: suspend () -> MlsGroupManager.StagedCommit, ): CommitPublication { - requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate) - // A commit whose publish went unconfirmed leaves the group in - // `PendingPublish`, which correctly refuses new commits — but the only - // thing that ever resolved it was `restoreAll`, so one dropped socket - // wedged the group until the app was restarted. Retrying this group's - // obligations here makes the next attempt the recovery: republishing - // the same event is safe (a peer deduplicates it by id) and a retry - // that still fails leaves the group held exactly as before. - if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) { - retryPendingPublishObligations(onlyGroupId = nostrGroupId) - } - check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) { - "Group $nostrGroupId cannot prepare a local commit " + - "(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})" - } + // One lock per group from the gate check to the durable obligation. The auto-commit of a + // member's leave runs from ingest while the user may be committing a rename or an add; + // the gate check alone let both pass before either recorded its obligation, and two + // commits staged from one epoch fork the group. + val (staged, event, obligation) = + commitPreparationLock(nostrGroupId).withLock { + requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate) + // A commit whose publish went unconfirmed leaves the group in + // `PendingPublish`, which correctly refuses new commits — but the only + // thing that ever resolved it was `restoreAll`, so one dropped socket + // wedged the group until the app was restarted. Retrying this group's + // obligations here makes the next attempt the recovery: republishing + // the same event is safe (a peer deduplicates it by id) and a retry + // that still fails leaves the group held exactly as before. + if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) { + retryPendingPublishObligations(onlyGroupId = nostrGroupId) + } + check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) { + "Group $nostrGroupId cannot prepare a local commit " + + "(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})" + } - val staged = stage() - val event = - outboundProcessor.buildCommitEvent( - nostrGroupId = nostrGroupId, - commitBytes = staged.result.framedCommitBytes, - exporterKey = staged.result.preCommitExporterSecret, - ) + val staged = stage() + val event = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = staged.result.framedCommitBytes, + exporterKey = staged.result.preCommitExporterSecret, + ) - // Durable BEFORE the publish. Publishing first would leave a crash - // window in which peers have accepted a commit this client has no - // memory of preparing — and on restart it would generate a - // replacement, forking itself at the same epoch. - val obligation = - publishGate.prepare( - groupId = nostrGroupId, - staged = staged, - outboundBytes = event.signedEvent.toJson().encodeToByteArray(), - recipientScope = relays.map { it.url }, - ) + // Durable BEFORE the publish. Publishing first would leave a crash + // window in which peers have accepted a commit this client has no + // memory of preparing — and on restart it would generate a + // replacement, forking itself at the same epoch. + val obligation = + publishGate.prepare( + groupId = nostrGroupId, + staged = staged, + outboundBytes = event.signedEvent.toJson().encodeToByteArray(), + recipientScope = relays.map { it.url }, + ) + Triple(staged, event, obligation) + } val confirmed = try { @@ -1548,25 +1633,51 @@ class MarmotManager( * targets together, since a deletion is only authorized against the message * it names. */ - fun deletedIds(messages: List): Set { + fun deletedIds( + messages: List, + /** The group's admins: their kind-4891 removals apply to anyone's message. */ + admins: Set = emptySet(), + ): Set { val authorOf = HashMap(messages.size) val claims = ArrayList>() + val removed = HashSet() for (event in messages) { if (event.kind == DeletionRequestEvent.KIND) { for (tag in event.tags) { if (tag.size >= 2 && tag[0] == "e") claims.add(tag[1] to event.pubKey) } + } else if (event.kind == MarmotAppEvent.KIND_REMOVE) { + if (event.pubKey in admins) removed.addAll(adminRemovalTargets(event)) } else { authorOf[event.id] = event.pubKey } } - val deleted = HashSet(claims.size) + val deleted = HashSet(claims.size + removed.size) for ((targetId, deleter) in claims) { if (authorOf[targetId] == deleter) deleted.add(targetId) } + deleted.addAll(removed.filter { it in authorOf }) return deleted } + /** + * The messages a kind-4891 removal names, if its author may remove them: a current + * admin of [nostrGroupId]. Empty for anything else. White Noise sends 4891 instead of + * kind 5 whenever the deleter is an admin (their own messages included), so without + * this an admin's deletion from White Noise never reached us. + */ + fun adminRemovalTargets( + nostrGroupId: HexKey, + event: Event, + ): List { + if (event.kind != MarmotAppEvent.KIND_REMOVE) return emptyList() + val admins = groupView(nostrGroupId)?.adminPubkeys ?: return emptyList() + if (event.pubKey !in admins) return emptyList() + return adminRemovalTargets(event) + } + + private fun adminRemovalTargets(event: Event): List = event.tags.mapNotNull { tag -> if (tag.size >= 2 && tag[0] == "e") tag[1] else null } + /** * The slice of canonical group state that kind:1210 rows are derived from, * or null when this client is not in the group. @@ -2014,6 +2125,30 @@ class MarmotManager( }.event } + /** + * Commit a staged departure when this account may: the step [commitPendingProposals] + * describes, driven from ingest. + * + * Nothing called it, so when a White Noise member left a group whose only admin was + * this account, the SelfRemove sat in the pool forever: the leaver stayed in the tree + * (still able to decrypt the group), and both apps kept listing them. Only admins + * commit here, the authority MIP-03 gives; another admin committing the same + * proposal at the same time is an ordinary fork that convergence settles. A failure + * is logged, not thrown: it is follow-up work, and the proposal stays staged for the + * next attempt. + */ + suspend fun commitStagedProposalsIfAdmin(nostrGroupId: HexKey): OutboundGroupEvent? { + val view = groupView(nostrGroupId) ?: return null + if (signer.pubKey !in view.adminPubkeys) return null + return try { + commitPendingProposals(nostrGroupId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("MarmotManager") { "could not commit staged proposals for ${nostrGroupId.take(8)}…: ${e.message}" } + null + } + } + /** * Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as * `disbanded` in a Commit every member replays. @@ -2676,6 +2811,12 @@ class MarmotManager( // events the UI never sees directly — a disband request resolving, a // removal being realized. chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId) + chatroom.isOutOfSync.value = desync.isDesynced(nostrGroupId) + // A group we created is ours: keep it out of "New Requests" across restarts. + // `markAsKnown` at creation is in-memory only, and a creator who has not posted + // yet has nothing else that says so. See [isCreatedLocally] for why this is not + // read off the tree. + if (isCreatedLocally(nostrGroupId)) chatroom.ownerSentMessage = true val previousCount = chatroom.members.value.size val members = memberPubkeys(nostrGroupId) chatroom.members.value = members @@ -2696,6 +2837,9 @@ class MarmotManager( */ internal val GROUP_EVENT_REFETCH_OVERLAP_SEC: Long = TimeUtils.ONE_DAY.toLong() + /** Prefix of the inbound error for an app message no epoch here can open. */ + internal const val NO_CANONICAL_EPOCH_ERROR = "Application message decrypts on no canonical epoch" + /** * How often the settler re-checks an open pass. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt index c8cb6f5726..bf579e4ab7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSyncPolicy.kt @@ -143,12 +143,24 @@ class MarmotSyncPolicy( } if (filterMap.isEmpty()) return - val events = drain(filterMap, timeoutMs) + // Relays answer newest-first and several relays interleave, but a kind:445 can only + // be opened at the epoch its predecessors built: a message sent after a commit fails + // (UndecryptableOuter) if it is tried before that commit. The + // cursor then moves past it and it is never fetched again, which is how an offline + // member came back missing a rename and every message after a membership change. + // Welcomes first (they create the groups), then group events oldest first. + val events = + drain(filterMap, timeoutMs) + .distinctBy { it.second.id } + .sortedWith(compareBy({ if (it.second.kind == GiftWrapEvent.KIND) 0 else 1 }, { it.second.createdAt })) var maxGwSeen = gwSince ?: 0L val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap() var sawGiftWrap = false val sawGroupEvent = mutableSetOf() + val stagedProposals = mutableSetOf() + val waitingOnEpoch = mutableListOf() + var advancedEpoch = false for ((relay, event) in events) { // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — @@ -161,6 +173,9 @@ class MarmotSyncPolicy( else -> "" } log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail") + if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId) + if (event.kind == GroupEvent.KIND && result.couldOpenAfterACommit()) waitingOnEpoch.add(event) + if (result is MarmotIngestResult.Commit) advancedEpoch = true when (event.kind) { GiftWrapEvent.KIND -> { @@ -177,6 +192,27 @@ class MarmotSyncPolicy( } } + // Same-second ties and cross-relay stragglers can still put an event ahead of the + // commit it needs; once a commit landed, give those another go, until a pass + // opens nothing new. + while (advancedEpoch && waitingOnEpoch.isNotEmpty()) { + advancedEpoch = false + val retry = waitingOnEpoch.toList() + waitingOnEpoch.clear() + for (event in retry) { + val result = marmot.ingest(event) + log("retry ${event.kind}/${event.id.take(8)} → ${result::class.simpleName}") + if (result is MarmotIngestResult.Commit) advancedEpoch = true + if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId) + if (result.couldOpenAfterACommit()) waitingOnEpoch.add(event) + } + } + + // A member's SelfRemove only takes effect once an admin commits it. + for (gid in stagedProposals) { + marmot.commitStagedProposalsIfAdmin(gid)?.let { log("committed staged proposals for ${gid.take(8)} → ${it.signedEvent.id.take(8)}") } + } + if (sawGiftWrap && maxGwSeen > 0) { cursors.giftWrapSince = maxGwSeen } @@ -217,3 +253,11 @@ class MarmotSyncPolicy( const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60 } } + +/** + * Failed only because the epoch it was sent at isn't reached yet; a later commit may open it. + * Only an undecryptable outer layer qualifies. A "no canonical epoch" failure already opened + * its outer layer on an epoch we hold, so no commit can help it, and the inbound processor + * remembers its id: a retry would only come back as a duplicate. + */ +private fun MarmotIngestResult.couldOpenAfterACommit(): Boolean = this is MarmotIngestResult.UndecryptableOuter diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 05aa779b29..c95d460266 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -90,6 +90,12 @@ class MarmotGroupChatroom( */ var isCurrentProfile = MutableStateFlow(true) + /** This device can no longer read what the other members send (see MarmotDesyncDetector). */ + val isOutOfSync = MutableStateFlow(false) + + /** This device dropped its copy of the group and waits for an admin to add it back. */ + val awaitingReinvite = MutableStateFlow(false) + /** * True once the group carries the `encrypted-media-v2` policy (`0x800b`). * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 63be18ff2b..fcd4f2ed18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -38,6 +38,12 @@ class MarmotGroupList( private val noteToGroupIndex = LargeCache() + // Message id -> group, for every message a group admin removed (kind 4891). A removal can + // decrypt before its target: a catch-up returns newest first, and both sit in one epoch. + // Unlike a kind-5 there is no LocalCache deletion index behind it, so without this record a + // target arriving second would be added and stay, restarts included. + private val adminRemovedIds = LargeCache() + private val _groupListChanges = MutableSharedFlow(0, 20, BufferOverflow.DROP_OLDEST) val groupListChanges = _groupListChanges @@ -48,6 +54,7 @@ class MarmotGroupList( msg: Note, ) { if (!isDisplayableFeedMessage(msg)) return + if (adminRemovedIds.get(msg.idHex) == nostrGroupId) return val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.addMessageSync(msg)) { noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } @@ -99,6 +106,19 @@ class MarmotGroupList( } } + /** + * Applies an admin removal of [targetId] in [nostrGroupId]: drops [target] if it is already + * shown, and keeps the removal so the message is refused if it arrives later. + */ + fun applyAdminRemoval( + nostrGroupId: HexKey, + targetId: HexKey, + target: Note?, + ) { + adminRemovedIds.put(targetId, nostrGroupId) + if (target != null) removeMessage(nostrGroupId, target) + } + /** * Drop a group from the in-memory list. Also clears the chatroom's own * message set and the note→group index: LocalCache holds notes weakly, so @@ -146,7 +166,9 @@ class MarmotGroupList( * authorship rule below. */ private fun isDisplayableFeedMessage(msg: Note): Boolean { - val kind = msg.event?.kind ?: return true + // Every path here indexes the decrypted inner event onto its note first, so a note + // without one can't be classified and would only render as a "can't be found" row. + val kind = msg.event?.kind ?: return false if (kind == MARMOT_INNER_KIND_SYSTEM_ROW) return isOwnDerivedSystemRow(msg) return kind !in NON_CHAT_INNER_KINDS } @@ -175,6 +197,7 @@ class MarmotGroupList( companion object { private const val MARMOT_INNER_KIND_DELETION = 5 + private const val MARMOT_INNER_KIND_ADMIN_REMOVAL = 4891 private const val MARMOT_INNER_KIND_REACTION = 7 private const val MARMOT_INNER_KIND_EDIT = 1009 private const val MARMOT_INNER_KIND_STREAM_START = 1200 @@ -190,6 +213,7 @@ class MarmotGroupList( private val NON_CHAT_INNER_KINDS = setOf( MARMOT_INNER_KIND_DELETION, + MARMOT_INNER_KIND_ADMIN_REMOVAL, MARMOT_INNER_KIND_REACTION, MARMOT_INNER_KIND_EDIT, MARMOT_INNER_KIND_STREAM_START, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt new file mode 100644 index 0000000000..fd16921495 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt @@ -0,0 +1,47 @@ +/* + * 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.commons.model.mediaServers + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl + +/** + * The Blossom servers an upload tries, in order: the one the user picked, then the rest + * of the list the picker offered them (their kind-10063 servers, or the defaults when + * they have none), top down. + * + * Servers differ in what they accept, and nothing tells the user in advance: primal, + * azzamo and blossom.band answer an encrypted (application/octet-stream) blob with + * 415, some demand payment, some are simply down. With a single target, picking the + * wrong one meant a failed upload and a retry by hand; the settings screen already told + * users uploads "try each server from the top down". + * + * Blossom only: a NIP-96 or NIP-95 upload keeps its single target, because falling back + * from one would change the kind of event the upload produces. + */ +fun blossomUploadOrder( + selected: ServerName, + offered: List, +): List { + if (selected.type != ServerType.Blossom) return listOf(selected) + val seen = mutableSetOf(BlossomServerUrl.domain(selected.baseUrl)) + val rest = offered.filter { it.type == ServerType.Blossom && seen.add(BlossomServerUrl.domain(it.baseUrl)) } + return listOf(selected) + rest +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt index 1caf3dacd4..3ec74e6a8e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupFeedVisibilityTest.kt @@ -105,4 +105,38 @@ class MarmotGroupFeedVisibilityTest { assertTrue(list.getOrCreateGroup(groupId).messages.size == 0, "a payload cannot vouch for itself") assertFalse(list.groupIdForNote(forged.idHex) == groupId) } + + @Test + fun `a note with no event never becomes a row`() { + // Our own push-token answers (448) were indexed with a bare justConsume, which + // does not dispatch that kind, so their notes stayed eventless — and an eventless + // note skipped the kind rules above and rendered "Event is loading or can't be found". + assertEquals(0, visibleCount(list(), Note("d".repeat(64)))) + } + + @Test + fun `an admin removal that arrives before its target still removes it`() { + // A catch-up returns newest first, so the kind-4891 can decrypt before the message. + val list = list() + val target = note(9, peer, content = "spam") + list.applyAdminRemoval(groupId, target.idHex, null) + assertEquals(0, visibleCount(list, target)) + } + + @Test + fun `an admin removal in one group does not hide the same id in another`() { + val list = list() + val target = note(9, peer, content = "hello") + list.applyAdminRemoval("e".repeat(64), target.idHex, null) + assertEquals(1, visibleCount(list, target)) + } + + @Test + fun `an admin removal drops a message already shown`() { + val list = list() + val target = note(9, peer, content = "spam") + assertEquals(1, visibleCount(list, target)) + list.applyAdminRemoval(groupId, target.idHex, target) + assertEquals(0, list.getOrCreateGroup(groupId).messages.size) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt new file mode 100644 index 0000000000..9aeaee304e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt @@ -0,0 +1,55 @@ +/* + * 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.commons.model.mediaServers + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UploadFallbackTest { + private val band = ServerName("Nostr.Build", "https://blossom.band/") + private val primal = ServerName("Primal", "https://blossom.primal.net/") + private val yaki = ServerName("YakiHonne", "https://blossom.yakihonne.com/") + + @Test + fun `the picked server goes first and the rest follow in list order`() { + assertEquals(listOf(primal, band, yaki), blossomUploadOrder(primal, listOf(band, primal, yaki))) + } + + @Test + fun `a server is never tried twice, even under another spelling`() { + val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET") + assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band))) + } + + @Test + fun `a server picked from outside the list is still first`() { + val custom = ServerName("mine", "https://blobs.example.com") + assertEquals(listOf(custom, band), blossomUploadOrder(custom, listOf(band))) + } + + @Test + fun `non-Blossom uploads do not fall back`() { + val nip96 = ServerName("nostr.build", "https://nostr.build", ServerType.NIP96) + assertEquals(listOf(nip96), blossomUploadOrder(nip96, listOf(band, nip96))) + // Nor does a Blossom upload fall back onto a NIP-96 server. + assertEquals(listOf(band), blossomUploadOrder(band, listOf(nip96))) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 9f28a12765..c8b47520d9 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.Reencode import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.File +import kotlin.coroutines.cancellation.CancellationException data class UploadResult( val blossom: BlossomUploadResult, @@ -76,6 +79,8 @@ class UploadOrchestrator( quality: CompressionQuality? = null, bypassReencode: Boolean = false, preCompressed: File? = null, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): UploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -133,12 +138,14 @@ class UploadOrchestrator( // 5. Upload. val result = - client.upload( - file = finalFile, - contentType = metadata.mimeType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + file = finalFile, + contentType = metadata.mimeType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return UploadResult(blossom = result, metadata = metadata) } finally { @@ -171,6 +178,8 @@ class UploadOrchestrator( // When true it's uploaded with the real media type — less private, but // required by strict Blossom servers that reject octet-stream (HTTP 415). declareRealMimeType: Boolean = false, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): EncryptedUploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -230,13 +239,17 @@ class UploadOrchestrator( // 415) also work, at the cost of leaking the media category. val uploadContentType = if (declareRealMimeType) metadata.mimeType else "application/octet-stream" + // The same ciphertext goes to every server tried, so the cipher and the + // encrypted hash stay valid wherever it lands. val result = - client.upload( - bytes = encrypted, - contentType = uploadContentType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + bytes = encrypted, + contentType = uploadContentType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return EncryptedUploadResult( blossom = result, @@ -251,4 +264,31 @@ class UploadOrchestrator( } } } + + /** + * [serverBaseUrl] first, then each of [fallbacks] (skipping duplicates by domain) until + * one accepts. Servers differ in what they take -- several answer an opaque encrypted + * blob with 415, paid ones with 402 -- and the user can't see that in advance. The auth + * header is not server-scoped, so the same one serves every attempt. When all fail, the + * first server's error is thrown: that is the server the user chose. + */ + private suspend fun uploadToFirstAccepting( + serverBaseUrl: String, + fallbacks: List, + upload: suspend (String) -> BlossomUploadResult, + ): BlossomUploadResult { + val seen = mutableSetOf() + val order = (listOf(serverBaseUrl) + fallbacks).filter { seen.add(BlossomServerUrl.domain(it)) } + var firstError: Exception? = null + for (server in order) { + try { + return upload(server) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (firstError == null) firstError = e + Log.w("UploadOrchestrator") { "Upload to $server failed (${e.message}), trying the next server" } + } + } + throw firstError!! + } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt new file mode 100644 index 0000000000..c2797f5491 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDesyncDetectorTest.kt @@ -0,0 +1,85 @@ +/* + * 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.commons.marmot + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MarmotDesyncDetectorTest { + private val g = "a".repeat(64) + + private fun id(n: Int) = n.toString().padStart(64, '0') + + @Test + fun `peer messages newer than our last decrypt that keep failing flag the group`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + assertFalse(d.onUndecryptable(g, id(1), 1_010)) + assertFalse(d.onUndecryptable(g, id(2), 1_030)) + assertTrue(d.onUndecryptable(g, id(3), 1_080)) + assertTrue(d.isDesynced(g)) + } + + @Test + fun `history from before our last decrypt never counts`() { + // Old epochs (before we joined, or past retention) fail the same way. + val d = MarmotDesyncDetector() + d.seed(g, 5_000) + (1..20).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_000L + it * 100)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a burst from one moment is not enough`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + (1..10).forEach { assertFalse(d.onUndecryptable(g, id(it), 1_010L + it)) } + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a replayed event counts once`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + repeat(5) { d.onUndecryptable(g, id(1), 1_010) } + d.onUndecryptable(g, id(2), 1_100) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `one decrypt clears the flag`() { + val d = MarmotDesyncDetector() + d.seed(g, 1_000) + d.onUndecryptable(g, id(1), 1_010) + d.onUndecryptable(g, id(2), 1_040) + d.onUndecryptable(g, id(3), 1_090) + assertTrue(d.onDecrypted(g, 1_100)) + assertFalse(d.isDesynced(g)) + } + + @Test + fun `a group with no baseline is never flagged`() { + val d = MarmotDesyncDetector() + (1..5).forEach { d.onUndecryptable(g, id(it), 1_000L + it * 100) } + assertFalse(d.isDesynced(g)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt index 547ec25c2b..4a8a9b30e8 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotDisbandTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.marmot import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.quartz.marmot.GroupEventResult +import com.vitorpamplona.quartz.marmot.InMemoryIngestDedupStore import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -260,6 +262,81 @@ class MarmotDisbandTest { assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value) } + @Test + fun `a group we created stays Known after a restart, an invitation does not`() = + runBlocking { + // "Known" at creation was an in-memory flag, so after a restart a creator who + // had not posted yet found their own group under New Requests. The sync that + // runs on restore has to reach the same answer from the MLS state alone. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + val alicesRoom = MarmotGroupChatroom(nostrGroupId) + val bobsRoom = MarmotGroupChatroom(nostrGroupId) + alice.manager.syncMetadataTo(nostrGroupId, alicesRoom) + bob.manager.syncMetadataTo(nostrGroupId, bobsRoom) + + assertTrue(alicesRoom.isKnown(emptySet()), "the creator's own group") + assertTrue(!bobsRoom.isKnown(emptySet()), "an invitation from someone we don't follow") + } + + @Test + fun `a member reset after falling out of sync is back in once re-added`() = + runBlocking { + // The recovery for a device stuck on a dead epoch: it drops its copy without + // publishing anything, an admin removes and re-adds it, and the new Welcome + // joins instead of being taken for a replay of a group it still holds. + val alice = Fixture() + val bob = Fixture() + alice.createCurrentProfile() + val kp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList()) + bob.manager.ingest(welcome!!.giftWrapEvent) + + bob.manager.resetOutOfSyncGroup(nostrGroupId) + assertNull(bob.manager.groupState(nostrGroupId), "the stale copy is gone") + + val bobLeaf = + alice.manager + .memberPubkeys(nostrGroupId) + .first { it.pubkey == bob.signer.pubKey } + .leafIndex + alice.manager.removeMember(nostrGroupId, bobLeaf) + val freshKp = bob.manager.generateKeyPackageEvent(relays = emptyList()) + val (_, reinvite) = alice.manager.addMember(nostrGroupId, freshKp, emptyList()) + val joined = bob.manager.ingest(reinvite!!.giftWrapEvent) + assertTrue(joined is MarmotIngestResult.JoinedGroup, "re-invite joins, got $joined") + + val hello = alice.manager.buildTextMessage(nostrGroupId, "welcome back") + val received = bob.manager.processGroupEvent(hello.outbound.signedEvent) + assertTrue(received is GroupEventResult.ApplicationMessage, "and reads the group again, got $received") + } + + @Test + fun `a group we created is still ours after a restart`() = + runBlocking { + // The marker is what survives, not the tree position: a fresh manager on the same + // stores has to find it on restore. + val signer = NostrSignerInternal(KeyPair()) + val states = SnapshotStateStore() + val messages = SnapshotMessageStore() + val bundles = SnapshotBundleStore() + val markers = InMemoryIngestDedupStore() + val before = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers) + before.createCurrentProfileGroup(nostrGroupId, listOf("wss://relay.invalid"), GroupProfileV1("mine", "")) + + val after = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers) + after.restoreAll() + val room = MarmotGroupChatroom(nostrGroupId) + after.syncMetadataTo(nostrGroupId, room) + + assertTrue(room.isKnown(emptySet()), "the creator's own group, after a restart") + } + @Test fun `rejoining a group we left clears the departure gate`() = runBlocking { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt index e2ea1de62e..0dd1ca3c03 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt @@ -140,6 +140,48 @@ class MarmotEditsAndSystemRowsTest { assertTrue(mine.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents())) } + @Test + fun `an admin's kind 4891 removal retracts a message, a non-admin's does not`() = + runBlocking { + // White Noise sends 4891 instead of kind 5 whenever the deleter is an admin, + // for their own messages too; missing it meant those deletions never applied. + val f = Fixture() + f.manager.createGroup( + nostrGroupId, + MarmotGroupData( + nostrGroupId = nostrGroupId, + name = "moderated", + relays = listOf("wss://relay.invalid"), + adminPubkeys = listOf(f.signer.pubKey), + ), + ) + val target = f.manager.buildTextMessage(nostrGroupId, "moderate me") + val admin = f.signer.pubKey + val stranger = "f".repeat(64) + + fun removal(by: String) = + MarmotAppEvent.build( + pubKey = by, + kind = MarmotAppEvent.KIND_REMOVE, + content = """{"v":1,"action":"remove"}""", + createdAt = 1_800_000_000L, + tags = arrayOf(arrayOf("e", target.innerEvent.id)), + ) + val byStranger = removal(stranger) + f.manager.persistDecryptedMessage(nostrGroupId, byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}") + val admins = setOf(admin) + assertTrue(target.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents(), admins)) + assertTrue(f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}")).isEmpty()) + + val byAdmin = removal(admin) + f.manager.persistDecryptedMessage(nostrGroupId, byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}") + assertTrue(target.innerEvent.id in f.manager.deletedIds(f.manager.storedEvents(), admins)) + assertEquals( + listOf(target.innerEvent.id), + f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}")), + ) + } + @Test fun `one kind 5 retracts every message it names`() = runBlocking { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt index cbd5b71043..493648b9ea 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotLeaveProposalTest.kt @@ -105,6 +105,35 @@ class MarmotLeaveProposalTest { ) } + @Test + fun `after ingest an admin commits a staged departure and a non-admin does not`() = + runBlocking { + // The app and amy call commitStagedProposalsIfAdmin on every ProposalStaged. + // Before that nothing did, and a member who left a group we administer never left. + val alice = Fixture() + val bob = Fixture() + val carol = Fixture() + alice.manager.createCurrentProfileGroup( + nostrGroupId = nostrGroupId, + relays = listOf("wss://relay.invalid"), + profile = GroupProfileV1("departures", ""), + ) + val (_, bobWelcome) = alice.manager.addMember(nostrGroupId, bob.manager.generateKeyPackageEvent(relays = emptyList()), emptyList()) + bob.manager.ingest(bobWelcome!!.giftWrapEvent) + val (addCarol, carolWelcome) = alice.manager.addMember(nostrGroupId, carol.manager.generateKeyPackageEvent(relays = emptyList()), emptyList()) + bob.manager.ingest(addCarol!!.signedEvent) + carol.manager.ingest(carolWelcome!!.giftWrapEvent) + + val proposal = carol.manager.leaveGroup(nostrGroupId) + assertIs(bob.manager.ingest(proposal.signedEvent)) + assertIs(alice.manager.ingest(proposal.signedEvent)) + + assertNull(bob.manager.commitStagedProposalsIfAdmin(nostrGroupId), "bob is not an admin") + assertNotNull(alice.manager.commitStagedProposalsIfAdmin(nostrGroupId), "alice is") + assertEquals(2, alice.manager.memberCount(nostrGroupId)) + assertNull(alice.manager.commitStagedProposalsIfAdmin(nostrGroupId), "nothing left to commit") + } + @Test fun `every member applies the commit that evicts the leaver, not just the committer`() = runBlocking { diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index eb8f051c0d..45bda2bbd6 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -3021,7 +3021,7 @@ فشل رفع الصوت: %1$s فشل رفع الرسالة الصوتية NIP-95 غير مدعوم للرسائل الصوتية بعد - %1$d%% وقت التشغيل + %1$d% وقت التشغيل محرك Tor النشط استخدم الإصدار الداخلي أو Orbot لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index a3394863e6..1e351c6777 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -2897,7 +2897,7 @@ ভয়েস আপলোড ব্যর্থ হয়েছে: %1$s ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে NIP-95 এখনও ভয়েস বার্তার জন্য সমর্থিত নয় - %1$d%% আপটাইম + %1$d% আপটাইম সক্রিয় Tor ইঞ্জিন অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index a59a25da1f..f97f30c142 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -325,7 +325,7 @@ Přidejte alespoň jednu keš. Zveřejnění se nezdařilo. Zkontrolujte připojení k relayům a zkuste to znovu. Zapy na toto se rozdělují mezi: - %1$d%% + %1$d% Value-for-Value Otevření Čtení @@ -3114,7 +3114,7 @@ %1$d relayů odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika - %1$d %% ze všech + %1$d % ze všech Aktivní odběry relayů %1$d filtr zatím není přiřazen @@ -5352,7 +5352,7 @@ Nahrávání hlasu selhalo: %1$s Nepodařilo se nahrát hlasovou zprávu NIP-95 zatím není pro hlasové zprávy podporován - %1$d%% dostupnost + %1$d% dostupnost Aktivní Tor Engine Použijte interní verzi nebo Orbot Uživatel nemá nastavenou LN adresu pro přijímání sats diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index df7de8f834..a6b7b9fd49 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -313,7 +313,7 @@ Füge mindestens einen Cache hinzu. Veröffentlichen fehlgeschlagen. Prüfe deine Relay-Verbindung und versuche es erneut. Zaps hierauf werden aufgeteilt zwischen: - %1$d%% + %1$d% Value-for-Value Öffnen Lesen @@ -3088,7 +3088,7 @@ %1$d Relays abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose - %1$d %% von allen + %1$d % von allen Aktive Relay-Abonnements %1$d Filter ist noch nicht zugeordnet @@ -5136,7 +5136,7 @@ Sprach-Upload fehlgeschlagen: %1$s Sprachnachricht konnte nicht hochgeladen werden NIP-95 wird für Sprachnachrichten noch nicht unterstützt - %1$d%% Verfügbarkeit + %1$d% Verfügbarkeit Aktiver Tor-Engine Verwende die interne Version oder Orbot Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen diff --git a/commonsUI/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml index a062bd2b0d..a9ecda02cd 100644 --- a/commonsUI/src/commonMain/composeResources/values-de/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de/strings.xml @@ -2988,7 +2988,7 @@ Sprach-Upload fehlgeschlagen: %1$s Sprachnachricht konnte nicht hochgeladen werden NIP-95 wird für Sprachnachrichten noch nicht unterstützt - %1$d%% Verfügbarkeit + %1$d% Verfügbarkeit Aktiver Tor-Engine Verwenden Sie die interne Version oder Orbot Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index 16c37e259b..7d8881a6a3 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -2846,7 +2846,7 @@ Αποτυχία ανεβάσματος φωνητικού: %1$s Αποτυχία ανεβάσματος φωνητικού μηνύματος Το NIP-95 δεν υποστηρίζεται ακόμα για φωνητικά μηνύματα - %1$d%% λειτουργία + %1$d% λειτουργία Ενεργή μηχανή Tor Χρησιμοποιήστε την εσωτερική έκδοση ή το Orbot Ο χρήστης δεν έχει ορίσει διεύθυνση LN για να λαμβάνει sats diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index 41f7ad82ec..1f2164b0ee 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -2901,7 +2901,7 @@ Voĉa alŝuto malsukcesis: %1$s Malsukcesis alŝuti voĉan mesaĝon NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj - %1$d%% funkcia tempo + %1$d% funkcia tempo Aktiva Tor-Motoro Uzu la internan version aŭ Orbot Uzanto ne havas lightning-adreson por ricevi satojn diff --git a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml index 3274fa4e7d..797f4b70f0 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml @@ -2952,7 +2952,7 @@ Voĉa alŝuto malsukcesis: %1$s Malsukcesis alŝuti voĉan mesaĝon NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj - %1$d%% funkcia tempo + %1$d% funkcia tempo Aktiva Tor-Motoro Uzu la internan version aŭ Orbot Uzanto ne havas lightning-adreson por ricevi satojn diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index 22de7cb7f9..269e81bc18 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -2915,7 +2915,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene una configuración de dirección Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index 72e983759b..ec35d46499 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -2908,7 +2908,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene configurada una dirección de Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index 324e61dcb2..1122a9df6a 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -2908,7 +2908,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene configurada una dirección de Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index b50a02847d..c3ca94cced 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2952,7 +2952,7 @@ Error al cargar voz: %1$s Error al cargar el mensaje de voz NIP-95 aún no es compatible con mensajes de voz - %1$d%% de disponibilidad + %1$d% de disponibilidad Motor de Tor activo Usar la versión interna u Orbot El usuario no tiene una configuración de dirección Lightning para recibir sats diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index 3077ca6903..ce1d4cdafe 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -2909,7 +2909,7 @@ آپلود صوتی ناموفق بود: %1$s آپلود پیام صوتی ناموفق بود NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود - %1$d%% زمان فعالیت + %1$d% زمان فعالیت موتور فعال تور استفاده از نسخه درونی یا Orbot این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است diff --git a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml index d45ac6d794..29be72a76e 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml @@ -2952,7 +2952,7 @@ آپلود صوتی ناموفق بود: %1$s آپلود پیام صوتی ناموفق بود NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود - %1$d%% زمان فعالیت + %1$d% زمان فعالیت موتور فعال تور استفاده از نسخه درونی یا Orbot این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index f7927b5a97..a4ae7810a9 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -2877,7 +2877,7 @@ Ääniviesti epäonnistui: %1$s Ääniviesti epäonnistui ladata NIP-95 ei tue ääniviestiä vielä - %1$d%% käytettävyys + %1$d% käytettävyys Aktiivinen Tor-moottori Käytä sisäistä versiota tai Orbotia Käyttäjällä ei ole asetettuna Lightning-osoitetta satsien vastaanottamiseksi diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 8feba34b61..27a0712c55 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -2802,7 +2802,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index 7ffcdad0ac..d1ca232f3a 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -3092,7 +3092,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml index 183135accd..136defe6e8 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml @@ -2952,7 +2952,7 @@ Échec de l'envoi de la voix : %1$s Échec de l'envoi du message vocal NIP-95 n'est pas encore supporté pour les messages vocaux - %1$d%% de disponibilité + %1$d% de disponibilité Activer le moteur Tor Utiliser la version interne ou Orbot L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index a518b1b8dc..8c4f15602e 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5147,7 +5147,7 @@ ध्वनि आरोहण असफल : %1$s ध्वनि सन्देश आरोहण असफल निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए - %1$d%% समय निरन्तर उपलब्ध + %1$d% समय निरन्तर उपलब्ध सक्रिय टोर उपकरण आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index c33e43e167..19096e20f9 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -313,7 +313,7 @@ Adjon hozzá legalább egy ládát. Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra. Az erre küldött Zapek megoszlanak a következők között: - %1$d%% + %1$d% Értéket az értékért Megnyitás Olvasás @@ -3088,7 +3088,7 @@ %1$d átjátszó előfizetések, szűrők, átjátszók, kérések, kapcsolatok, miért diagnosztika - %1$d%% az összesből + %1$d% az összesből Aktív átjátszó-előfizetések %1$d szűrő még nincs hozzárendelve @@ -5134,7 +5134,7 @@ Nem sikerült feltölteni a hangüzenetet: %1$s Nem sikerült feltölteni a hangüzenetet A NIP-95 még nem támogatja a hangüzeneteket - Üzemidő: %1$d%% + Üzemidő: %1$d% Aktív Tor-motor Használja a beépített verziót vagy az Orbotot A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel diff --git a/commonsUI/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml index 55ec897974..d957d12d1c 100644 --- a/commonsUI/src/commonMain/composeResources/values-in/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in/strings.xml @@ -2915,7 +2915,7 @@ Unggahan suara gagal: %1$s Gagal mengunggah pesan suara NIP-95 belum didukung untuk pesan suara - %1$d%% uptime + %1$d% uptime Mesin Tor Aktif Gunakan versi internal atau Orbot Pengguna tidak memiliki alamat lightning (LN) yang diatur untuk menerima sats diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index 46a70f4033..940fd670d6 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -2841,7 +2841,7 @@ Caricamento vocale fallito: %1$s Impossibile caricare il messaggio vocale NIP-95 non è ancora supportato per i messaggi vocali - %1$d%% di uptime + %1$d% di uptime Motore Tor attivo Usa la versione interna o Orbot L'utente non dispone di un indirizzo Lightning per ricevere sats diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index d05cf2e3ba..2fbc006550 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -2861,7 +2861,7 @@ ボイスのアップロードに失敗しました: %1$s ボイスメッセージのアップロードに失敗しました NIP-95 はボイスメッセージにまだ対応していません - %1$d%%稼働率 + %1$d%稼働率 Tor エンジンを有効化 内蔵版または Orbot を使用 satsを受け取るためのLightningアドレスが設定されていません diff --git a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml index e2b24e5fac..7791621a8a 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml @@ -2915,7 +2915,7 @@ ボイスのアップロードに失敗しました: %1$s ボイスメッセージのアップロードに失敗しました NIP-95 はボイスメッセージにまだ対応していません - %1$d%%稼働率 + %1$d%稼働率 Tor エンジンを有効化 内蔵版または Orbot を使用 satsを受け取るためのLightningアドレスが設定されていません diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index b5f94f364a..a20e9d6e73 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -2846,7 +2846,7 @@ 음성 업로드 실패: %1$s 음성 메시지 업로드에 실패했습니다 NIP-95는 아직 음성 메시지를 지원하지 않습니다 - %1$d%% 업타임 + %1$d% 업타임 내장 Tor 엔진 내장 버전 또는 Orbot을 사용하세요 이 사용자는 sats를 받을 Lightning 주소가 설정되어 있지 않습니다 diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index 159c676759..24f166016f 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -2930,7 +2930,7 @@ Balss augšupielāde neizdevās: %1$s Neizdevās augšupielādēt balss ziņojumu NIP-95 balss ziņojumiem vēl netiek atbalstīts - %1$d%% darbalaiks + %1$d% darbalaiks Aktīvs Tor dzinējs Izmantot iekšējo versiju vai Orbot Lietotājs nav iestatījis lightning adresi sats saņemšanai diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml index b47be71e33..f6bda663e7 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml @@ -2952,7 +2952,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Interne Tor-engine gebruiken Gebruik de ingebouwde versie van Orbot Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml index cc49452a22..cbaa5f2203 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml @@ -163,7 +163,7 @@ Weggebeurtenis Zaps hiervoor worden verdeeld over: - %1$d%% + %1$d% Value-for-Value Openen Lezen @@ -2615,7 +2615,7 @@ %1$d relays abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek - %1$d%% van alles + %1$d% van alles Actieve relay-abonnementen %1$d filter is nog niet toegewezen @@ -4567,7 +4567,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Interne Tor-engine gebruiken Gebruik de ingebouwde versie van Orbot Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml index 062bc8ef1b..2720341499 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml @@ -2952,7 +2952,7 @@ Spraakbericht uploaden mislukt: %1$s Spraakbericht uploaden mislukt NIP-95 wordt nog niet ondersteund voor spraakberichten - %1$d%% uptime + %1$d% uptime Actieve Tor Engine Gebruik de interne versie van Orbot Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index a9bdb24080..8d50a907f5 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -325,7 +325,7 @@ Dodaj co najmniej jedną skrzynkę. Nie można opublikować. Sprawdź połączenie z transmiterem i spróbuj ponownie. Zaps do tego są podzielone między: - %1$d%% + %1$d% Wartość-za-wartość Otwórz Odczyt @@ -3144,7 +3144,7 @@ %1$d transmitery subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka - %1$d%% z wszystkich + %1$d% z wszystkich Aktywne subskrypcje transmitera %1$d filtr nie został jeszcze przypisany @@ -5382,7 +5382,7 @@ Nie udało się przesłać głosu: %1$s Nie udało się przesłać wiadomości głosowej NIP-95 nie jest jeszcze dostępny dla wiadomości głosowych - Czas działania %1$d%% + Czas działania %1$d% Aktywny silnik Tor Użyj wersji wewnętrznej lub Orbota Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index 7fa393e1b3..d71a888f5b 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -313,7 +313,7 @@ Adicione pelo menos um cache. Não foi possível publicar. Verifique sua conexão com os relays e tente novamente. Os zaps para isto são divididos entre: - %1$d%% + %1$d% Valor-para-Valor Abrir Leitura @@ -3088,7 +3088,7 @@ %1$d Relés assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico - %1$d%% de todos + %1$d% de todos Assinaturas de relay ativas %1$d filtro ainda não foi atribuído @@ -5134,7 +5134,7 @@ Falha no upload de voz: %1$s Falha ao enviar mensagem de voz O NIP-95 ainda não é suportado para mensagens de voz - %1$d%% de disponibilidade + %1$d% de disponibilidade Motor Tor Ativo Use a versão interna ou o Orbot Usuário não tem um endereço lightning configurado para receber sats diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index 5225a9ebf9..cc543a7c38 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -2861,7 +2861,7 @@ Falha no upload de voz: %1$s Falha ao enviar mensagem de voz O NIP-95 ainda não é suportado para mensagens de voz - %1$d%% de disponibilidade + %1$d% de disponibilidade Motor Tor Ativo Use a versão interna ou o Orbot Usuário não tem um endereço lightning configurado para receber sats diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 85e7e02487..940d0affed 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -2948,7 +2948,7 @@ Ошибка загрузки голосового сообщения: %1$s Неудалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot Пользователь не установил Lightning адрес для получения чаевых diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index a9cb955bbe..1eb0aa80d8 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -2930,7 +2930,7 @@ Ошибка загрузки голосового сообщения: %1$s Не удалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot У пользователя не настроен Lightning-адрес для получения sats diff --git a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml index be13796940..7c8d0ca239 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml @@ -3026,7 +3026,7 @@ Ошибка загрузки голосового сообщения: %1$s Не удалось загрузить голосовое сообщение NIP-95 пока не поддерживается для голосовых сообщений - %1$d%% времени работы + %1$d% времени работы Встроенный движок Tor Использовать встроенную версию или Orbot Пользователь не установил Lightning адрес для получения чаевых diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 9f7b4ef715..12b230a326 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -175,7 +175,7 @@ Dogodek na cesti Zapi bojo razdeljeni med: - %1$d%% + %1$d% Vrednost za vrednost Odprto Branje @@ -2800,7 +2800,7 @@ %1$d relejev naročnine, filtri, releji, zahteve, povezave, zakaj, diagnostika - %1$d%% od vseh + %1$d% od vseh Aktivne rele naročnine %1$d filter še ni dodeljen @@ -4940,7 +4940,7 @@ Neuspešno nalaganje zvočnega posnetka: %1$s Neuspešno nalaganje zvočnega sporočila NIP-95 še ne podpira zvočnih posnetkov - %1$d%% časa delovanja + %1$d% časa delovanja Aktivni Tor stroj Uporabi interno različico ali Orbot Uporabnik nima nastavljenega "lightning" naslova za sprejem satoshi-jev diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index c7b1d4b59c..2d1f5bdc6b 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -2907,7 +2907,7 @@ Otpremanje glasa nije uspelo: %1$s Otpremanje glasovne poruke nije uspelo NIP-95 još nije podržan za glasovne poruke - %1$d%% dostupnost + %1$d% dostupnost Aktivan Tor motor Koristite internu verziju ili Orbot Korisnik nema podešenu Lightning adresu za primanje sats-a diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index 05544e3d76..4a2cf970c1 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -313,7 +313,7 @@ Lägg till minst en cache. Kunde inte publicera. Kontrollera reläanslutningen och försök igen. Zaps till detta fördelas mellan: - %1$d%% + %1$d% Värde -för-värde Öppna Läs @@ -3088,7 +3088,7 @@ %1$d reläer prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik - %1$d %% av alla + %1$d % av alla Aktiva reläprenumerationer %1$d filter är inte kopplat ännu @@ -5134,7 +5134,7 @@ Uppladdning av röst misslyckades: %1$s Misslyckades med att ladda upp röstmeddelandet NIP-95 stöds ännu inte för röstmeddelanden - %1$d%% drifttid + %1$d% drifttid Aktiv Tor Engine Använd den interna versionen eller Orbot Användaren har inte en Lightningadressinställning för att ta emot sats diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 286d845f7c..8732ae50c3 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -2882,7 +2882,7 @@ Upakiaji wa sauti umeshindwa: %1$s Imeshindwa kupakia ujumbe wa sauti NIP-95 haijatarajiwa kwa ujumbe wa sauti bado - %1$d%% wakati wa uendeshaji + %1$d% wakati wa uendeshaji Injini ya Tor Inayofanya Kazi Tumia toleo la ndani au Orbot Mtumiaji hana anwani ya umeme iliyoandaliwa kupokea sati diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 1118006456..1ff151032d 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -2870,7 +2870,7 @@ குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை - %1$d%% இயக்க நேரம் + %1$d% இயக்க நேரம் செயலில் Tor எஞ்சின் உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும் பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை diff --git a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml index 0c9046b656..a6fa3f2130 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml @@ -2952,7 +2952,7 @@ குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை - %1$d%% இயக்க நேரம் + %1$d% இயக்க நேரம் செயலில் Tor எஞ்சின் உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும் பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index cf2b33f695..6d86c4be9d 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -2710,7 +2710,7 @@ การอัปโหลดเสียงล้มเหลว: %1$s ไม่สามารถอัปโหลดข้อความเสียงได้ NIP-95 ยังไม่รองรับข้อความเสียง - เวลาทำงาน %1$d%% + เวลาทำงาน %1$d% เครื่องยนต์ Tor ที่ใช้งานอยู่ ใช้เวอร์ชันภายในหรือ Orbot ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats diff --git a/commonsUI/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml index 869f2c34ae..cdb57bb5be 100644 --- a/commonsUI/src/commonMain/composeResources/values-th/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th/strings.xml @@ -2915,7 +2915,7 @@ การอัปโหลดเสียงล้มเหลว: %1$s ไม่สามารถอัปโหลดข้อความเสียงได้ NIP-95 ยังไม่รองรับข้อความเสียง - เวลาทำงาน %1$d%% + เวลาทำงาน %1$d% เครื่องยนต์ Tor ที่ใช้งานอยู่ ใช้เวอร์ชันภายในหรือ Orbot ผู้ใช้นี้ไม่ได้ตั้งค่า lightning address เพื่อรับ sats diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index 885e0cedad..c4206f01e1 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -2877,7 +2877,7 @@ Ses yükleme başarısız: %1$s Sesli mesaj yüklenemedi NIP-95 henüz sesli mesajlar için desteklenmiyor - %1$d%% çalışma süresi + %1$d% çalışma süresi Aktif Tor Motoru Dahili sürümü veya Orbot'u kullanın Kullanıcının sats alabileceği bir lightning adresi kurulu değil diff --git a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml index 234cc91ca6..a54077a881 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml @@ -2952,7 +2952,7 @@ Ses yükleme başarısız: %1$s Sesli mesaj yüklenemedi NIP-95 henüz sesli mesajlar için desteklenmiyor - %1$d%% çalışma süresi + %1$d% çalışma süresi Aktif Tor Motoru Dahili sürümü veya Orbot'u kullanın Kullanıcının sats alabileceği bir lightning adresi kurulu değil diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index cdb6fd0b15..a15f4f5fdd 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -2935,7 +2935,7 @@ Помилка завантаження голосу: %1$s Не вдалося завантажити голосове повідомлення NIP-95 ще не підтримується для голосових повідомлень - %1$d%% доступності + %1$d% доступності Активний рушій Tor Використовувати вбудовану версію або Orbot Користувач не встановив Lightning адресу для отримання чайових diff --git a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml index 7910decae3..8e96de5dd5 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml @@ -3026,7 +3026,7 @@ Помилка завантаження голосу: %1$s Не вдалося завантажити голосове повідомлення NIP-95 ще не підтримується для голосових повідомлень - %1$d%% доступності + %1$d% доступності Активний рушій Tor Використовувати вбудовану версію або Orbot Користувач не встановив Lightning адресу для отримання чайових diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 349da89d24..129ee57d59 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -2886,7 +2886,7 @@ Ovoz yuklash muvaffaqiyatsiz tugadi: %1$s Ovozli xabarni yuklash muvaffaqiyatsiz tugadi NIP-95 ovozli xabarlar uchun hali qo'llab-quvvatlanmaydi - %1$d%% ishga tayyor vaqt + %1$d% ishga tayyor vaqt Faol Tor Mexanizmi Ichki versiya yoki Orbot'dan foydalaning Foydalanuvchi sats qabul qilish uchun lightning manzilini o'rnatmagan diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index 41d1564cbe..cc30d18764 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -2832,7 +2832,7 @@ Tải lên giọng nói thất bại: %1$s Không thể tải lên tin nhắn thoại NIP-95 chưa được hỗ trợ cho tin nhắn thoại - %1$d%% thời gian hoạt động + %1$d% thời gian hoạt động Bật công cụ Tor Sử dụng phiên bản nội bộ hoặc Orbot Người dùng chưa thiết lập địa chỉ Lightning để nhận sats diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index 068f70ecd9..dc09927a64 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -126,7 +126,7 @@ 道路事件 对此的Zaps 在以下对象间分割: - %1$d%% + %1$d% 值对值 打开 读取 @@ -3845,7 +3845,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index 09ac836145..0812f168a7 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -2894,7 +2894,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未設置閃電地址以接收聰 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index d3164d768c..f835004df2 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -2894,7 +2894,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index cdc0f88ed8..2651ec3289 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -2866,7 +2866,7 @@ 語音上傳失敗:%1$s 語音訊息上傳失敗 NIP-95 尚不支援語音訊息 - %1$d%% 正常運行時間 + %1$d% 正常運行時間 啟用 Tor 引擎 使用內建版本或 Orbot 用户尚未設置閃電地址以接收聰 diff --git a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml index 76a3e30f4c..6d7ae350e0 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml @@ -2951,7 +2951,7 @@ 语音上传失败:%1$s 上传语音消息失败 语音消息尚不支持 NIP-95 - %1$d%% 运行时间 + %1$d% 运行时间 启动 Tor 使用内置 Tor 或者 Orbot 用户尚未设置闪电地址以接收聪 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index c98506f31e..868ac7ec31 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -326,7 +326,7 @@ Add at least one cache. Could not publish. Check your relay connection and try again. Zaps to this are split between: - %1$d%% + %1$d% Value-for-Value Open Read @@ -721,6 +721,8 @@ Remove this media from your Gallery. Request Deletion Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored. + Amethyst will ask everyone in this encrypted group to delete this message. Their apps should remove it, but there is no guarantee that every member will delete their copy. + Amethyst will send an encrypted deletion request to the people in this conversation. Their apps should remove the message, but there is no guarantee that every copy will be deleted. Block Delete Block @@ -3159,7 +3161,7 @@ %1$d relays subscriptions filters relays requests reqs connections why diagnostics - %1$d%% of all + %1$d% of all Active Relay Subscriptions %1$d filter is not attributed yet @@ -4173,6 +4175,11 @@ Admin privileges granted Admin privileges revoked Change photo + Messages from the other members can't be read on this device anymore. Reset your copy of the group, then ask an admin to remove you and add you back. + Reset + Reset this group? + This device stops being a member until an admin adds you back. The messages you already have stay here. + Waiting to be added back. Ask an admin to remove you and add you again. Add a Blossom media server in Settings first — the group needs somewhere to upload attachments to. This group now uses encrypted attachments Failed to add %1$s: %2$s @@ -5257,7 +5264,7 @@ Voice upload failed: %1$s Failed to upload voice message NIP-95 is not supported for voice messages yet - %1$d%% uptime + %1$d% uptime Active Tor Engine Use the internal version or Orbot User does not have a lightning address set up to receive sats diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt index 3583623b52..c04a634407 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatBubbleLayout.kt @@ -300,8 +300,11 @@ fun ChatBubbleLayout( Modifier } + // A top-level bubble's row spans the screen so the bubble can sit on its side. A quoted + // reply is drawn INSIDE another bubble, where filling the width pushed that bubble out to + // its maximum: a one-word answer to a one-word message stretched across the screen. Row( - modifier = Modifier.fillMaxWidth().then(swipeModifier), + modifier = (if (innerQuote) Modifier else Modifier.fillMaxWidth()).then(swipeModifier), horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, ) { InnerChatBubble( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fe7a29c773..c44212493f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -336,6 +336,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -350,6 +351,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -361,6 +363,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -371,6 +374,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c34aea979b..cd139fe605 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -1410,7 +1410,7 @@ private suspend fun sendEncryptedFiles( // unsupported file can't abort the whole send. val fileQuality = if (file.extension.lowercase() in IMAGE_EXTENSIONS) quality else null val result = - orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType) + orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) val url = result.blossom.url ?: continue val template = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 317b36f1c4..44cb45d946 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -172,7 +172,8 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val blossomServers = LocalBlossomServers.current + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, @@ -182,7 +183,7 @@ fun EditProfileDialog( scope.launch(Dispatchers.IO) { setUploading(true) try { - val result = orchestrator.upload(file, null, serverBaseUrl, account.signer) + val result = orchestrator.upload(file, null, serverBaseUrl, account.signer, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) result.blossom.url?.let { onUrl(it) } } catch (e: CancellationException) { throw e diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt index 04ba37a589..326b036f98 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -222,4 +223,80 @@ class UploadOrchestratorTest { file.delete() } } + + @Test + fun encryptedUploadFallsBackToTheNextServerWithTheSameCiphertext() = + runTest { + // A server that refuses opaque blobs (415) must not fail the upload while + // another listed server would take it -- and the retry has to send the very + // bytes the cipher and hash describe. + val mockClient = mockk() + val servers = mutableListOf() + val bodies = mutableListOf() + coEvery { + mockClient.upload(bytes = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { + val server = arg(2) + servers += server + bodies += arg(0) + if (server.contains("primal")) { + throw IllegalStateException("Unsupported Media Type") + } + BlossomUploadResult(url = "$server/blob") + } + + val file = File.createTempFile("test_", ".bin") + file.deleteOnExit() + file.writeBytes(ByteArray(64) { it.toByte() }) + val mockSigner = mockk(relaxed = true) + + try { + val result = + UploadOrchestrator(mockClient).uploadEncrypted( + file = file, + cipher = AESGCM(), + serverBaseUrl = "https://blossom.primal.net", + signer = mockSigner, + fallbackServerBaseUrls = listOf("https://BLOSSOM.PRIMAL.NET/", "https://nostr.download"), + ) + + assertEquals(listOf("https://blossom.primal.net", "https://nostr.download"), servers) + assertTrue(bodies[0].contentEquals(bodies[1])) + assertEquals("https://nostr.download/blob", result.blossom.url) + } finally { + file.delete() + } + } + + @Test + fun whenEveryServerFailsTheSelectedServersErrorIsThrown() = + runTest { + val mockClient = mockk() + coEvery { + mockClient.upload(file = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { throw IllegalStateException("refused by ${arg(2)}") } + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + val mockSigner = mockk(relaxed = true) + + try { + val error = + kotlin + .runCatching { + UploadOrchestrator(mockClient).upload( + file = file, + alt = null, + serverBaseUrl = "https://a.example", + signer = mockSigner, + stripExif = false, + fallbackServerBaseUrls = listOf("https://b.example"), + ) + }.exceptionOrNull() + assertEquals("refused by https://a.example", error?.message) + } finally { + file.delete() + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt index e63c2f7d2f..b23f7f1e65 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt @@ -83,6 +83,13 @@ class MarmotAppEvent( /** A durable group system row, synthesized from canonical state. */ const val KIND_SYSTEM = 1210 + /** + * An admin's removal of a message (anyone's, their own included), with an `e` tag + * naming the target and `{"v":1,"action":"remove"}` as content. White Noise sends + * it instead of a kind-5 deletion whenever the deleter is a group admin. + */ + const val KIND_REMOVE = 4891 + private val ALLOWED_MEMBERS = setOf("id", "pubkey", "created_at", "kind", "tags", "content") val EMPTY_TAGS: TagArray = emptyArray() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index a252731f40..782b3effe4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -782,8 +782,14 @@ class MlsGroup private constructor( // Without this ordering the committer uses the PRE-commit context and // every strict-validating member derives a different AEAD key, turning // the decryption into `UpdatePathError(UnableToDecrypt)`. + // A required path is sent even when the proposals leave this leaf alone in the tree + // (a disband, or removing the last other member): the direct path is then empty, and + // the UpdatePath carries just the new leaf. RFC 9420 §12.4.1 makes the path mandatory + // for any Remove regardless of the tree's size, and openmls/MDK reject a Remove commit + // without one as `RequiredPathNotFound` — so every such commit we sent was dropped by + // White Noise, and the member we removed kept a live copy of the group. val updatePath: UpdatePath? = - if (needsPath && pathSecrets.isNotEmpty()) { + if (needsPath) { // RFC 9420 §7.9: UpdatePath carries one node per entry in the // **filtered** direct path — parents whose copath subtree has // empty resolution are omitted (encrypting to them is @@ -949,11 +955,18 @@ class MlsGroup private constructor( // else can reach, and every member rejects the commit with a // confirmation-tag mismatch. A SelfRemove-only commit — a departing // member's eviction — is precisely the case that omits the path. + // + // A path over an EMPTY direct path (this leaf alone in the tree) derives no node + // secrets, and openmls's `derive_path` then returns the starting path secret itself + // as the commit secret — zero derivation steps. Nobody else can decrypt that epoch + // anyway; it only has to be what openmls would compute. val commitSecret = - if (updatePath != null && pathSecrets.isNotEmpty()) { - MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path") - } else { + if (updatePath == null) { ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) + } else if (pathSecrets.isEmpty()) { + leafSecret + } else { + MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path") } val newTreeHash = tree.treeHash() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 6f41f3911f..fb075f828b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.mls.framing.MlsMessage import com.vitorpamplona.quartz.mls.framing.PublicMessage import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -87,6 +88,61 @@ class MlsGroupNegativeTest { ) } + /** + * Removing the last other member leaves the committer alone, so its direct path is + * empty — but RFC 9420 §12.4.1 still requires an UpdatePath for any Remove. We used to + * omit it, and openmls/MDK dropped the commit (`RequiredPathNotFound`): White Noise never + * learned it had been removed or that the group was disbanded. + */ + @Test + fun removingTheLastOtherMemberStillCarriesAnUpdatePath() { + val fx = twoMemberGroup() + val result = fx.alice.removeMember(1) + val commit = Commit.decodeTls(TlsReader(result.commitBytes)) + val path = commit.updatePath + assertTrue(path != null, "a Remove commit must carry an UpdatePath even with no path nodes") + assertEquals(0, path.nodes.size, "a one-leaf tree has an empty direct path") + assertEquals(1, fx.alice.members().size) + } + + /** + * The empty-path commit also changes how the committer derives the new epoch (the commit + * secret is the leaf's own path secret). No remaining member re-derives it, so the check + * is that the group stays usable: the removed member sees the removal, and a member + * added afterwards exchanges messages with the committer in both directions. + */ + @Test + fun groupKeepsWorkingAfterAnEmptyPathCommit() { + val fx = twoMemberGroup() + val removal = fx.alice.removeMember(1) + val parts = parseCommit(removal.framedCommitBytes) + fx.bob.processCommit( + commitBytes = parts.content, + senderLeafIndex = parts.senderLeafIndex, + confirmationTag = parts.confirmationTag, + signature = parts.signature, + wireFormat = WireFormat.PUBLIC_MESSAGE, + ) + // Bob applies his own removal: the empty path does not stop the tree update. + assertEquals(1, fx.bob.members().size) + + val daveBundle = fx.alice.createKeyPackage(identity = "dave".encodeToByteArray(), signingKey = ByteArray(32) { 3 }) + val addDave = fx.alice.addMember(daveBundle.keyPackage.toTlsBytes()) + val dave = MlsGroup.processWelcome(addDave.welcomeBytes!!, daveBundle) + assertEquals(fx.alice.epoch, dave.epoch) + + val fromAlice = fx.alice.encrypt("after the removal".encodeToByteArray()) + assertEquals("after the removal", dave.decrypt(fromAlice).content.decodeToString()) + val fromDave = dave.encrypt("and back".encodeToByteArray()) + assertEquals( + "and back", + fx.alice + .decrypt(fromDave) + .content + .decodeToString(), + ) + } + /** Baseline: an honest commit applies and advances Bob's epoch. */ @Test fun honestCommitIsAccepted() { diff --git a/tools/strings-migrate/fix_escapes.py b/tools/strings-migrate/fix_escapes.py index 65c7a7c566..7cfef30b83 100755 --- a/tools/strings-migrate/fix_escapes.py +++ b/tools/strings-migrate/fix_escapes.py @@ -78,8 +78,17 @@ def fix_text(text: str, unwrap_quotes: bool = True, source: str = None) -> str: """ if unwrap_quotes and len(text) >= 2 and text.startswith('"') and text.endswith('"'): # Quoted: aapt kept this whitespace exactly, so Compose keeps it too. - return ANDROID_ONLY.sub(r"\1", text[1:-1]) - return normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source) + return unescape_percent(ANDROID_ONLY.sub(r"\1", text[1:-1])) + return unescape_percent(normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source)) + + +def unescape_percent(text: str) -> str: + """`%%` is a printf escape Compose never resolves: `%1$d%%` renders `100%%`. + + Compose substitutes only `%N$s`/`%N$d` and leaves every other `%` alone, so a + bare `%` is already literal. Idempotent. + """ + return text.replace("%%", "%") def strip_android_only_attrs(src: str) -> tuple: