Merge pull request #4245 from vitorpamplona/fix/marmot-interop-batch

Marmot/White Noise interop batch: 22 fixes, tested on tablet + WN Android + WN iOS
This commit is contained in:
Vitor Pamplona
2026-09-28 10:55:03 -04:00
committed by GitHub
124 changed files with 2219 additions and 392 deletions
+5
View File
@@ -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
@@ -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",
@@ -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<Long, LatestKeyPackageOwner>? = 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<HexKey>()
// 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<HexKey>()
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<String> =
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))
}
}
@@ -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<SelectedMedia>,
) {
private var list: List<SelectedMediaProcessing> = 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<SelectedMediaProcessing> by mutableStateOf(uris.map { SelectedMediaProcessing(it) })
@Stable
class Result(
@@ -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<BlossomAuthorizationEvent>? = 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)
@@ -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
}
}
@@ -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
@@ -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)) }
},
)
}
}
@@ -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),
)
}
}
}
@@ -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()
},
@@ -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,
@@ -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 = {
@@ -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
@@ -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() {
@@ -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() {
@@ -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) {
@@ -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
@@ -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 —
@@ -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
@@ -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,
@@ -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,
@@ -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,
)
@@ -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 =
@@ -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<GroupMemberInfo>,
myPubKey: HexKey,
): List<HexKey> {
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<HexKey>,
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(", ")
}
@@ -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<Note?>(null)
// My own message being replaced; the next send publishes a kind:1009 edit of it.
val editingMessage = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(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
@@ -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) {
@@ -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. */
@@ -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,
@@ -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
}
/**
@@ -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),
)
}
}
}
@@ -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() {
@@ -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() {
@@ -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() {
@@ -401,11 +401,16 @@ private fun EncryptedKeyCard(
var encrypted by remember { mutableStateOf<String?>(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 {
@@ -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() {
@@ -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
@@ -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()
}
}
}
}
@@ -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
}
}
@@ -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.
+23 -1
View File
@@ -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=<sha>` 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
```
+38
View File
@@ -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.<pid>.<name>/<name>`, ~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
+23
View File
@@ -199,6 +199,29 @@ jq_list() {
' 2>/dev/null || true
}
# Whether <b|c>'s wn lists <hex> in the group's <members|admins>. 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 <hex> 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 <b|c>'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() {
@@ -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
+2
View File
@@ -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() {
+25 -15
View File
@@ -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"
+108 -12
View File
@@ -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
}
+15 -2
View File
@@ -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:-<none>}" >>"$LOG_FILE"
@@ -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<HexKey, Long>()
var desynced = false
}
private val lock = KmpLock()
private val groups = mutableMapOf<HexKey, Tracker>()
/** 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) }
}
}
@@ -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<HexKey>()
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<HexKey>()
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<HexKey>): Map<HexKey, Long> {
private suspend fun newestStoredMessageTimes(groupIds: Set<HexKey>): Map<HexKey, Long> {
if (messageStore == null) return emptyMap()
val result = mutableMapOf<HexKey, Long>()
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<Event>(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<Event>(kind = MarmotAppEvent.KIND_EDIT, description = replacement) {
addUnique(arrayOf("e", targetEventId))
}
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<Event>(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<HexKey, Mutex>()
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<Event>): Set<HexKey> {
fun deletedIds(
messages: List<Event>,
/** The group's admins: their kind-4891 removals apply to anyone's message. */
admins: Set<HexKey> = emptySet(),
): Set<HexKey> {
val authorOf = HashMap<HexKey, HexKey>(messages.size)
val claims = ArrayList<Pair<HexKey, HexKey>>()
val removed = HashSet<HexKey>()
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<HexKey>(claims.size)
val deleted = HashSet<HexKey>(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<HexKey> {
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<HexKey> = 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.
*
@@ -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<HexKey>()
val stagedProposals = mutableSetOf<HexKey>()
val waitingOnEpoch = mutableListOf<Event>()
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
@@ -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`).
*
@@ -38,6 +38,12 @@ class MarmotGroupList(
private val noteToGroupIndex = LargeCache<HexKey, HexKey>()
// 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<HexKey, HexKey>()
private val _groupListChanges = MutableSharedFlow<HexKey>(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,
@@ -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<ServerName>,
): List<ServerName> {
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
}
@@ -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)
}
}
@@ -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)))
}
}
@@ -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<String> = 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<String> = 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<String>,
upload: suspend (String) -> BlossomUploadResult,
): BlossomUploadResult {
val seen = mutableSetOf<String>()
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!!
}
}
@@ -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))
}
}
@@ -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 {
@@ -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 {
@@ -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<MarmotIngestResult.ProposalStaged>(bob.manager.ingest(proposal.signedEvent))
assertIs<MarmotIngestResult.ProposalStaged>(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 {
@@ -3021,7 +3021,7 @@
<string name="upload_error_voice_message_exception">فشل رفع الصوت: %1$s</string>
<string name="upload_error_voice_message_failed">فشل رفع الرسالة الصوتية</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 غير مدعوم للرسائل الصوتية بعد</string>
<string name="uptime">%1$d%% وقت التشغيل</string>
<string name="uptime">%1$d% وقت التشغيل</string>
<string name="use_internal_tor">محرك Tor النشط</string>
<string name="use_internal_tor_explainer">استخدم الإصدار الداخلي أو Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats</string>
@@ -2897,7 +2897,7 @@
<string name="upload_error_voice_message_exception">ভয়েস আপলোড ব্যর্থ হয়েছে: %1$s</string>
<string name="upload_error_voice_message_failed">ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 এখনও ভয়েস বার্তার জন্য সমর্থিত নয়</string>
<string name="uptime">%1$d%% আপটাইম</string>
<string name="uptime">%1$d% আপটাইম</string>
<string name="use_internal_tor">সক্রিয় Tor ইঞ্জিন</string>
<string name="use_internal_tor_explainer">অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই</string>
@@ -325,7 +325,7 @@
<string name="geocache_hunt_needs_caches">Přidejte alespoň jednu keš.</string>
<string name="geocache_publish_failed">Zveřejnění se nezdařilo. Zkontrolujte připojení k relayům a zkuste to znovu.</string>
<string name="podcast_value_zap_split_hint">Zapy na toto se rozdělují mezi:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Otevření</string>
<string name="relay_monitor_rtt_read">Čtení</string>
@@ -3114,7 +3114,7 @@
<item quantity="other">%1$d relayů</item>
</plurals>
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
<string name="active_subs_share">%1$d %% ze všech</string>
<string name="active_subs_share">%1$d % ze všech</string>
<string name="active_subs_title">Aktivní odběry relayů</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr zatím není přiřazen</item>
@@ -5352,7 +5352,7 @@
<string name="upload_error_voice_message_exception">Nahrávání hlasu selhalo: %1$s</string>
<string name="upload_error_voice_message_failed">Nepodařilo se nahrát hlasovou zprávu</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 zatím není pro hlasové zprávy podporován</string>
<string name="uptime">%1$d%% dostupnost</string>
<string name="uptime">%1$d% dostupnost</string>
<string name="use_internal_tor">Aktivní Tor Engine</string>
<string name="use_internal_tor_explainer">Použijte interní verzi nebo Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string>
@@ -313,7 +313,7 @@
<string name="geocache_hunt_needs_caches">Füge mindestens einen Cache hinzu.</string>
<string name="geocache_publish_failed">Veröffentlichen fehlgeschlagen. Prüfe deine Relay-Verbindung und versuche es erneut.</string>
<string name="podcast_value_zap_split_hint">Zaps hierauf werden aufgeteilt zwischen:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Öffnen</string>
<string name="relay_monitor_rtt_read">Lesen</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d Relays</item>
</plurals>
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
<string name="active_subs_share">%1$d %% von allen</string>
<string name="active_subs_share">%1$d % von allen</string>
<string name="active_subs_title">Aktive Relay-Abonnements</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
@@ -5136,7 +5136,7 @@
<string name="upload_error_voice_message_exception">Sprach-Upload fehlgeschlagen: %1$s</string>
<string name="upload_error_voice_message_failed">Sprachnachricht konnte nicht hochgeladen werden</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wird für Sprachnachrichten noch nicht unterstützt</string>
<string name="uptime">%1$d%% Verfügbarkeit</string>
<string name="uptime">%1$d% Verfügbarkeit</string>
<string name="use_internal_tor">Aktiver Tor-Engine</string>
<string name="use_internal_tor_explainer">Verwende die interne Version oder Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
@@ -2988,7 +2988,7 @@
<string name="upload_error_voice_message_exception">Sprach-Upload fehlgeschlagen: %1$s</string>
<string name="upload_error_voice_message_failed">Sprachnachricht konnte nicht hochgeladen werden</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wird für Sprachnachrichten noch nicht unterstützt</string>
<string name="uptime">%1$d%% Verfügbarkeit</string>
<string name="uptime">%1$d% Verfügbarkeit</string>
<string name="use_internal_tor">Aktiver Tor-Engine</string>
<string name="use_internal_tor_explainer">Verwenden Sie die interne Version oder Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
@@ -2846,7 +2846,7 @@
<string name="upload_error_voice_message_exception">Αποτυχία ανεβάσματος φωνητικού: %1$s</string>
<string name="upload_error_voice_message_failed">Αποτυχία ανεβάσματος φωνητικού μηνύματος</string>
<string name="upload_error_voice_message_nip95_not_supported">Το NIP-95 δεν υποστηρίζεται ακόμα για φωνητικά μηνύματα</string>
<string name="uptime">%1$d%% λειτουργία</string>
<string name="uptime">%1$d% λειτουργία</string>
<string name="use_internal_tor">Ενεργή μηχανή Tor</string>
<string name="use_internal_tor_explainer">Χρησιμοποιήστε την εσωτερική έκδοση ή το Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Ο χρήστης δεν έχει ορίσει διεύθυνση LN για να λαμβάνει sats</string>
@@ -2901,7 +2901,7 @@
<string name="upload_error_voice_message_exception">Voĉa alŝuto malsukcesis: %1$s</string>
<string name="upload_error_voice_message_failed">Malsukcesis alŝuti voĉan mesaĝon</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj</string>
<string name="uptime">%1$d%% funkcia tempo</string>
<string name="uptime">%1$d% funkcia tempo</string>
<string name="use_internal_tor">Aktiva Tor-Motoro</string>
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uzanto ne havas lightning-adreson por ricevi satojn</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">Voĉa alŝuto malsukcesis: %1$s</string>
<string name="upload_error_voice_message_failed">Malsukcesis alŝuti voĉan mesaĝon</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj</string>
<string name="uptime">%1$d%% funkcia tempo</string>
<string name="uptime">%1$d% funkcia tempo</string>
<string name="use_internal_tor">Aktiva Tor-Motoro</string>
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uzanto ne havas lightning-adreson por ricevi satojn</string>
@@ -2915,7 +2915,7 @@
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
<string name="uptime">%1$d%% de disponibilidad</string>
<string name="uptime">%1$d% de disponibilidad</string>
<string name="use_internal_tor">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene una configuración de dirección Lightning para recibir sats</string>
@@ -2908,7 +2908,7 @@
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
<string name="uptime">%1$d%% de disponibilidad</string>
<string name="uptime">%1$d% de disponibilidad</string>
<string name="use_internal_tor">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene configurada una dirección de Lightning para recibir sats</string>
@@ -2908,7 +2908,7 @@
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
<string name="uptime">%1$d%% de disponibilidad</string>
<string name="uptime">%1$d% de disponibilidad</string>
<string name="use_internal_tor">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene configurada una dirección de Lightning para recibir sats</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
<string name="uptime">%1$d%% de disponibilidad</string>
<string name="uptime">%1$d% de disponibilidad</string>
<string name="use_internal_tor">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene una configuración de dirección Lightning para recibir sats</string>
@@ -2909,7 +2909,7 @@
<string name="upload_error_voice_message_exception">آپلود صوتی ناموفق بود: %1$s</string>
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود</string>
<string name="uptime">%1$d%% زمان فعالیت</string>
<string name="uptime">%1$d% زمان فعالیت</string>
<string name="use_internal_tor">موتور فعال تور</string>
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">آپلود صوتی ناموفق بود: %1$s</string>
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود</string>
<string name="uptime">%1$d%% زمان فعالیت</string>
<string name="uptime">%1$d% زمان فعالیت</string>
<string name="use_internal_tor">موتور فعال تور</string>
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
@@ -2877,7 +2877,7 @@
<string name="upload_error_voice_message_exception">Ääniviesti epäonnistui: %1$s</string>
<string name="upload_error_voice_message_failed">Ääniviesti epäonnistui ladata</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ei tue ääniviestiä vielä</string>
<string name="uptime">%1$d%% käytettävyys</string>
<string name="uptime">%1$d% käytettävyys</string>
<string name="use_internal_tor">Aktiivinen Tor-moottori</string>
<string name="use_internal_tor_explainer">Käytä sisäistä versiota tai Orbotia</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Käyttäjällä ei ole asetettuna Lightning-osoitetta satsien vastaanottamiseksi</string>
@@ -2802,7 +2802,7 @@
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
<string name="uptime">%1$d%% de disponibilité</string>
<string name="uptime">%1$d% de disponibilité</string>
<string name="use_internal_tor">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
@@ -3092,7 +3092,7 @@
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
<string name="uptime">%1$d%% de disponibilité</string>
<string name="uptime">%1$d% de disponibilité</string>
<string name="use_internal_tor">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
<string name="uptime">%1$d%% de disponibilité</string>
<string name="uptime">%1$d% de disponibilité</string>
<string name="use_internal_tor">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
@@ -5147,7 +5147,7 @@
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
<string name="upload_error_voice_message_failed">ध्वनि सन्देश आरोहण असफल</string>
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
<string name="uptime">%1$d% समय निरन्तर उपलब्ध</string>
<string name="use_internal_tor">सक्रिय टोर उपकरण</string>
<string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
@@ -313,7 +313,7 @@
<string name="geocache_hunt_needs_caches">Adjon hozzá legalább egy ládát.</string>
<string name="geocache_publish_failed">Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra.</string>
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Értéket az értékért</string>
<string name="relay_monitor_rtt_open">Megnyitás</string>
<string name="relay_monitor_rtt_read">Olvasás</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d átjátszó</item>
</plurals>
<string name="active_subs_search_keywords">előfizetések, szűrők, átjátszók, kérések, kapcsolatok, miért diagnosztika</string>
<string name="active_subs_share">%1$d%% az összesből</string>
<string name="active_subs_share">%1$d% az összesből</string>
<string name="active_subs_title">Aktív átjátszó-előfizetések</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d szűrő még nincs hozzárendelve</item>
@@ -5134,7 +5134,7 @@
<string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string>
<string name="upload_error_voice_message_failed">Nem sikerült feltölteni a hangüzenetet</string>
<string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string>
<string name="uptime">Üzemidő: %1$d%%</string>
<string name="uptime">Üzemidő: %1$d%</string>
<string name="use_internal_tor">Aktív Tor-motor</string>
<string name="use_internal_tor_explainer">Használja a beépített verziót vagy az Orbotot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string>
@@ -2915,7 +2915,7 @@
<string name="upload_error_voice_message_exception">Unggahan suara gagal: %1$s</string>
<string name="upload_error_voice_message_failed">Gagal mengunggah pesan suara</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 belum didukung untuk pesan suara</string>
<string name="uptime">%1$d%% uptime</string>
<string name="uptime">%1$d% uptime</string>
<string name="use_internal_tor">Mesin Tor Aktif</string>
<string name="use_internal_tor_explainer">Gunakan versi internal atau Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Pengguna tidak memiliki alamat lightning (LN) yang diatur untuk menerima sats</string>
@@ -2841,7 +2841,7 @@
<string name="upload_error_voice_message_exception">Caricamento vocale fallito: %1$s</string>
<string name="upload_error_voice_message_failed">Impossibile caricare il messaggio vocale</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 non è ancora supportato per i messaggi vocali</string>
<string name="uptime">%1$d%% di uptime</string>
<string name="uptime">%1$d% di uptime</string>
<string name="use_internal_tor">Motore Tor attivo</string>
<string name="use_internal_tor_explainer">Usa la versione interna o Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utente non dispone di un indirizzo Lightning per ricevere sats</string>
@@ -2861,7 +2861,7 @@
<string name="upload_error_voice_message_exception">ボイスのアップロードに失敗しました: %1$s</string>
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</string>
<string name="uptime">%1$d%%稼働率</string>
<string name="uptime">%1$d%稼働率</string>
<string name="use_internal_tor">Tor エンジンを有効化</string>
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">satsを受け取るためのLightningアドレスが設定されていません</string>
@@ -2915,7 +2915,7 @@
<string name="upload_error_voice_message_exception">ボイスのアップロードに失敗しました: %1$s</string>
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</string>
<string name="uptime">%1$d%%稼働率</string>
<string name="uptime">%1$d%稼働率</string>
<string name="use_internal_tor">Tor エンジンを有効化</string>
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">satsを受け取るためのLightningアドレスが設定されていません</string>
@@ -2846,7 +2846,7 @@
<string name="upload_error_voice_message_exception">음성 업로드 실패: %1$s</string>
<string name="upload_error_voice_message_failed">음성 메시지 업로드에 실패했습니다</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95는 아직 음성 메시지를 지원하지 않습니다</string>
<string name="uptime">%1$d%% 업타임</string>
<string name="uptime">%1$d% 업타임</string>
<string name="use_internal_tor">내장 Tor 엔진</string>
<string name="use_internal_tor_explainer">내장 버전 또는 Orbot을 사용하세요</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">이 사용자는 sats를 받을 Lightning 주소가 설정되어 있지 않습니다</string>
@@ -2930,7 +2930,7 @@
<string name="upload_error_voice_message_exception">Balss augšupielāde neizdevās: %1$s</string>
<string name="upload_error_voice_message_failed">Neizdevās augšupielādēt balss ziņojumu</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 balss ziņojumiem vēl netiek atbalstīts</string>
<string name="uptime">%1$d%% darbalaiks</string>
<string name="uptime">%1$d% darbalaiks</string>
<string name="use_internal_tor">Aktīvs Tor dzinējs</string>
<string name="use_internal_tor_explainer">Izmantot iekšējo versiju vai Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Lietotājs nav iestatījis lightning adresi sats saņemšanai</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
<string name="uptime">%1$d%% uptime</string>
<string name="uptime">%1$d% uptime</string>
<string name="use_internal_tor">Interne Tor-engine gebruiken</string>
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
@@ -163,7 +163,7 @@
<string name="road_event_unknown">Weggebeurtenis</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Openen</string>
<string name="relay_monitor_rtt_read">Lezen</string>
@@ -2615,7 +2615,7 @@
<item quantity="other">%1$d relays</item>
</plurals>
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string>
<string name="active_subs_share">%1$d%% van alles</string>
<string name="active_subs_share">%1$d% van alles</string>
<string name="active_subs_title">Actieve relay-abonnementen</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter is nog niet toegewezen</item>
@@ -4567,7 +4567,7 @@
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
<string name="uptime">%1$d%% uptime</string>
<string name="uptime">%1$d% uptime</string>
<string name="use_internal_tor">Interne Tor-engine gebruiken</string>
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
@@ -2952,7 +2952,7 @@
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
<string name="uptime">%1$d%% uptime</string>
<string name="uptime">%1$d% uptime</string>
<string name="use_internal_tor">Actieve Tor Engine</string>
<string name="use_internal_tor_explainer">Gebruik de interne versie van Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen</string>
@@ -325,7 +325,7 @@
<string name="geocache_hunt_needs_caches">Dodaj co najmniej jedną skrzynkę.</string>
<string name="geocache_publish_failed">Nie można opublikować. Sprawdź połączenie z transmiterem i spróbuj ponownie.</string>
<string name="podcast_value_zap_split_hint">Zaps do tego są podzielone między:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Wartość-za-wartość</string>
<string name="relay_monitor_rtt_open">Otwórz</string>
<string name="relay_monitor_rtt_read">Odczyt</string>
@@ -3144,7 +3144,7 @@
<item quantity="other">%1$d transmitery</item>
</plurals>
<string name="active_subs_search_keywords">subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka</string>
<string name="active_subs_share">%1$d%% z wszystkich</string>
<string name="active_subs_share">%1$d% z wszystkich</string>
<string name="active_subs_title">Aktywne subskrypcje transmitera</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr nie został jeszcze przypisany</item>
@@ -5382,7 +5382,7 @@
<string name="upload_error_voice_message_exception">Nie udało się przesłać głosu: %1$s</string>
<string name="upload_error_voice_message_failed">Nie udało się przesłać wiadomości głosowej</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 nie jest jeszcze dostępny dla wiadomości głosowych</string>
<string name="uptime">Czas działania %1$d%%</string>
<string name="uptime">Czas działania %1$d%</string>
<string name="use_internal_tor">Aktywny silnik Tor</string>
<string name="use_internal_tor_explainer">Użyj wersji wewnętrznej lub Orbota</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
@@ -313,7 +313,7 @@
<string name="geocache_hunt_needs_caches">Adicione pelo menos um cache.</string>
<string name="geocache_publish_failed">Não foi possível publicar. Verifique sua conexão com os relays e tente novamente.</string>
<string name="podcast_value_zap_split_hint">Os zaps para isto são divididos entre:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Valor-para-Valor</string>
<string name="relay_monitor_rtt_open">Abrir</string>
<string name="relay_monitor_rtt_read">Leitura</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d Relés</item>
</plurals>
<string name="active_subs_search_keywords">assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico</string>
<string name="active_subs_share">%1$d%% de todos</string>
<string name="active_subs_share">%1$d% de todos</string>
<string name="active_subs_title">Assinaturas de relay ativas</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
@@ -5134,7 +5134,7 @@
<string name="upload_error_voice_message_exception">Falha no upload de voz: %1$s</string>
<string name="upload_error_voice_message_failed">Falha ao enviar mensagem de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">O NIP-95 ainda não é suportado para mensagens de voz</string>
<string name="uptime">%1$d%% de disponibilidade</string>
<string name="uptime">%1$d% de disponibilidade</string>
<string name="use_internal_tor">Motor Tor Ativo</string>
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
@@ -2861,7 +2861,7 @@
<string name="upload_error_voice_message_exception">Falha no upload de voz: %1$s</string>
<string name="upload_error_voice_message_failed">Falha ao enviar mensagem de voz</string>
<string name="upload_error_voice_message_nip95_not_supported">O NIP-95 ainda não é suportado para mensagens de voz</string>
<string name="uptime">%1$d%% de disponibilidade</string>
<string name="uptime">%1$d% de disponibilidade</string>
<string name="use_internal_tor">Motor Tor Ativo</string>
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
@@ -2948,7 +2948,7 @@
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Неудалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
<string name="uptime">%1$d%% времени работы</string>
<string name="uptime">%1$d% времени работы</string>
<string name="use_internal_tor">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</string>
@@ -2930,7 +2930,7 @@
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
<string name="uptime">%1$d%% времени работы</string>
<string name="uptime">%1$d% времени работы</string>
<string name="use_internal_tor">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">У пользователя не настроен Lightning-адрес для получения sats</string>
@@ -3026,7 +3026,7 @@
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
<string name="uptime">%1$d%% времени работы</string>
<string name="uptime">%1$d% времени работы</string>
<string name="use_internal_tor">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</string>
@@ -175,7 +175,7 @@
<string name="road_event_unknown">Dogodek na cesti</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="podcast_value_zap_split_hint">Zapi bojo razdeljeni med:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Vrednost za vrednost</string>
<string name="relay_monitor_rtt_open">Odprto</string>
<string name="relay_monitor_rtt_read">Branje</string>
@@ -2800,7 +2800,7 @@
<item quantity="other">%1$d relejev</item>
</plurals>
<string name="active_subs_search_keywords">naročnine, filtri, releji, zahteve, povezave, zakaj, diagnostika</string>
<string name="active_subs_share">%1$d%% od vseh</string>
<string name="active_subs_share">%1$d% od vseh</string>
<string name="active_subs_title">Aktivne rele naročnine</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter še ni dodeljen</item>
@@ -4940,7 +4940,7 @@
<string name="upload_error_voice_message_exception">Neuspešno nalaganje zvočnega posnetka: %1$s</string>
<string name="upload_error_voice_message_failed">Neuspešno nalaganje zvočnega sporočila</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 še ne podpira zvočnih posnetkov</string>
<string name="uptime">%1$d%% časa delovanja</string>
<string name="uptime">%1$d% časa delovanja</string>
<string name="use_internal_tor">Aktivni Tor stroj</string>
<string name="use_internal_tor_explainer">Uporabi interno različico ali Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uporabnik nima nastavljenega "lightning" naslova za sprejem satoshi-jev</string>
@@ -2907,7 +2907,7 @@
<string name="upload_error_voice_message_exception">Otpremanje glasa nije uspelo: %1$s</string>
<string name="upload_error_voice_message_failed">Otpremanje glasovne poruke nije uspelo</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 još nije podržan za glasovne poruke</string>
<string name="uptime">%1$d%% dostupnost</string>
<string name="uptime">%1$d% dostupnost</string>
<string name="use_internal_tor">Aktivan Tor motor</string>
<string name="use_internal_tor_explainer">Koristite internu verziju ili Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Korisnik nema podešenu Lightning adresu za primanje sats-a</string>
@@ -313,7 +313,7 @@
<string name="geocache_hunt_needs_caches">Lägg till minst en cache.</string>
<string name="geocache_publish_failed">Kunde inte publicera. Kontrollera reläanslutningen och försök igen.</string>
<string name="podcast_value_zap_split_hint">Zaps till detta fördelas mellan:</string>
<string name="podcast_value_split_percent">%1$d%%</string>
<string name="podcast_value_split_percent">%1$d%</string>
<string name="podcast_value_for_value">Värde -för-värde</string>
<string name="relay_monitor_rtt_open">Öppna</string>
<string name="relay_monitor_rtt_read">Läs</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d reläer</item>
</plurals>
<string name="active_subs_search_keywords">prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik</string>
<string name="active_subs_share">%1$d %% av alla</string>
<string name="active_subs_share">%1$d % av alla</string>
<string name="active_subs_title">Aktiva reläprenumerationer</string>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter är inte kopplat ännu</item>
@@ -5134,7 +5134,7 @@
<string name="upload_error_voice_message_exception">Uppladdning av röst misslyckades: %1$s</string>
<string name="upload_error_voice_message_failed">Misslyckades med att ladda upp röstmeddelandet</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 stöds ännu inte för röstmeddelanden</string>
<string name="uptime">%1$d%% drifttid</string>
<string name="uptime">%1$d% drifttid</string>
<string name="use_internal_tor">Aktiv Tor Engine</string>
<string name="use_internal_tor_explainer">Använd den interna versionen eller Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
@@ -2882,7 +2882,7 @@
<string name="upload_error_voice_message_exception">Upakiaji wa sauti umeshindwa: %1$s</string>
<string name="upload_error_voice_message_failed">Imeshindwa kupakia ujumbe wa sauti</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 haijatarajiwa kwa ujumbe wa sauti bado</string>
<string name="uptime">%1$d%% wakati wa uendeshaji</string>
<string name="uptime">%1$d% wakati wa uendeshaji</string>
<string name="use_internal_tor">Injini ya Tor Inayofanya Kazi</string>
<string name="use_internal_tor_explainer">Tumia toleo la ndani au Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Mtumiaji hana anwani ya umeme iliyoandaliwa kupokea sati</string>
@@ -2870,7 +2870,7 @@
<string name="upload_error_voice_message_exception">குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s</string>
<string name="upload_error_voice_message_failed">குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது</string>
<string name="upload_error_voice_message_nip95_not_supported">குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை</string>
<string name="uptime">%1$d%% இயக்க நேரம்</string>
<string name="uptime">%1$d% இயக்க நேரம்</string>
<string name="use_internal_tor">செயலில் Tor எஞ்சின்</string>
<string name="use_internal_tor_explainer">உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும்</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>

Some files were not shown because too many files have changed in this diff Show More