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: if n:
found[path]["raw line break/tab in a value"] += 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 return found
@@ -1721,7 +1721,15 @@ class Account(
marmot.deleteMarmotMessages(groupId, groupNotes) 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()) { if (myNotes.isNotEmpty()) {
// chunks in 200 elements to avoid going over the 65KB limit for events. // chunks in 200 elements to avoid going over the 65KB limit for events.
myNotes.chunked(200).forEach { chunkedList -> myNotes.chunked(200).forEach { chunkedList ->
@@ -3979,9 +3987,9 @@ class Account(
// assertion by its sender and is dropped at ingest — so these are // assertion by its sender and is dropped at ingest — so these are
// safe to render with attribution. // safe to render with attribution.
marmotManager.onSystemRowDerived = { groupId, row -> marmotManager.onSystemRowDerived = { groupId, row ->
cache.justConsume(row, null, true) // Indexed like any inner event: a bare `note.event = row` left the row with no
val note = cache.getOrCreateNote(row.id) // author, which the Messages tab read as "No messages yet".
note.event = row val note = marmot.indexMarmotInnerEvent(row).note
marmotGroupList.addMessage(groupId, note) marmotGroupList.addMessage(groupId, note)
} }
@@ -4048,6 +4056,7 @@ class Account(
// kind:1009 edit is re-linked to its message too. // kind:1009 edit is re-linked to its message too.
val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note
marmotGroupList.addMessage(groupId, innerNote) marmotGroupList.addMessage(groupId, innerNote)
marmot.applyMarmotAdminRemoval(groupId, innerEvent)
} catch (e: Exception) { } catch (e: Exception) {
Log.w( Log.w(
"Account", "Account",
@@ -21,6 +21,9 @@
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note 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.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
@@ -88,6 +91,33 @@ class AccountMarmotActions(
/** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */ /** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */
private var lastOwnerCheck: Pair<Long, LatestKeyPackageOwner>? = null 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 * Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same * the MLS GroupContext metadata so every member converges on the same
@@ -128,12 +158,11 @@ class AccountMarmotActions(
nostrGroupId: HexKey, nostrGroupId: HexKey,
innerEvent: Event, innerEvent: Event,
) { ) {
// wasVerified=true: MIP-03 inner events are unsigned rumors, so // The same indexing as a received message. A bare justConsume returns false for a kind
// Schnorr verification would reject every one. This one we built // LocalCache does not dispatch (push-token lists 447-449, edits, stream starts), which
// ourselves, which is as authenticated as it gets. // left our own copy an EVENTLESS note in the conversation: an "Event is loading or can't
val isNew = account.cache.justConsume(innerEvent, null, true) // be found" row for every push-token answer we sent.
val innerNote = account.cache.getOrCreateNote(innerEvent.id) val innerNote = indexMarmotInnerEvent(innerEvent).note
if (isNew) innerNote.event = innerEvent
account.marmotGroupList.addMessage(nostrGroupId, innerNote) account.marmotGroupList.addMessage(nostrGroupId, innerNote)
// Sending a message moves the group out of "New Requests" into // Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before the relay round-trip so the UI // "Known" — do this eagerly before the relay round-trip so the UI
@@ -284,6 +313,21 @@ class AccountMarmotActions(
return IndexedInnerEvent(innerNote, isNew) 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. */ /** [note] holds the inner event; [isNew] is true the first time this client indexed it. */
class IndexedInnerEvent( class IndexedInnerEvent(
val note: Note, val note: Note,
@@ -591,6 +635,21 @@ class AccountMarmotActions(
lastOwnerCheck = null 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 * Ensure the local user has at least one active KeyPackage bundle and
* a published KeyPackage event on relays. Called from [init] after * 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 // Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message. // doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId) 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 * 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 * 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) { suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return val manager = account.marmotManager ?: return
if (!account.isWriteable()) return if (!account.isWriteable()) return
val servers = account.blossomServers.flow.value val servers = marmotMediaPolicyServers()
require(servers.isNotEmpty()) { require(servers.isNotEmpty()) {
"Cannot enable encrypted media without at least one Blossom server configured" "Cannot enable encrypted media without at least one Blossom server configured"
} }
@@ -1018,6 +1116,9 @@ class AccountMarmotActions(
if (view.adminPubkeys.contains(targetPubKey)) return if (view.adminPubkeys.contains(targetPubKey)) return
manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList()) 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()) 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 android.content.Context
import androidx.compose.runtime.Stable 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.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -37,7 +40,9 @@ import kotlinx.coroutines.launch
class MultiOrchestrator( class MultiOrchestrator(
uris: List<SelectedMedia>, 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 @Stable
class Result( class Result(
@@ -25,6 +25,7 @@ import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType 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.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required 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( private suspend fun uploadBlossom(
fileUri: Uri, fileUri: Uri,
contentType: String?, contentType: String?,
@@ -214,6 +267,7 @@ class UploadOrchestrator {
account: Account, account: Account,
forcedSigner: NostrSigner?, forcedSigner: NostrSigner?,
context: Context, context: Context,
sharedAuth: SharedUploadAuth,
): UploadingFinalState { ): UploadingFinalState {
updateState(0.2, UploadingState.Uploading) updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer // 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 // upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping // `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped). // guards against (delete tokens are — those stay scoped).
httpAuth = httpAuth = { hash, size, alt ->
when { sharedAuth.get {
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } when {
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) } forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) } useMedia -> account.createBlossomMediaAuth(hash, size, alt)
}, else -> account.createBlossomUploadAuth(hash, size, alt)
}
}
},
context = context, context = context,
useMediaEndpoint = useMedia, useMediaEndpoint = useMedia,
) )
@@ -271,13 +328,13 @@ class UploadOrchestrator {
finalState finalState
} catch (_: SignerExceptions.ReadOnlyException) { } 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) { } catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob. // 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) { } catch (e: Exception) {
if (e is CancellationException) throw e 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) { return when (server.type) {
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) 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.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 { } finally {
deleteTempUri(finalUri, uri) deleteTempUri(finalUri, uri)
@@ -527,7 +587,12 @@ class UploadOrchestrator {
return when (server.type) { return when (server.type) {
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) 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.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 { } finally {
deleteTempUri(encrypted.uri, uri) deleteTempUri(encrypted.uri, uri)
@@ -330,6 +330,9 @@ open class EditPostViewModel : ViewModel() {
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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) { 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 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.animation.core.animateFloatAsState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res 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_compressing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_downloading import com.vitorpamplona.amethyst.commons.resources.uploading_state_downloading
import com.vitorpamplona.amethyst.commons.resources.uploading_state_error import com.vitorpamplona.amethyst.commons.resources.uploading_state_error
@@ -216,10 +222,55 @@ fun OrchestratorOverlay(
val progress by orchestrator.progress.collectAsState() val progress by orchestrator.progress.collectAsState()
val progressState by orchestrator.progressState.collectAsState() val progressState by orchestrator.progressState.collectAsState()
if (progressState is UploadingState.Ready) { val state = progressState
if (state is UploadingState.Ready) {
DeleteButton(onDelete) DeleteButton(onDelete)
} else if (state is UploadingState.Error) {
UploadErrorBadge(progress, state, onDelete)
} else { } 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.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
@Composable @Composable
fun UploadProgressIndicator( fun UploadProgressIndicator(
@@ -60,12 +62,12 @@ fun UploadProgressIndicator(
val progressValue = orchestrator.progress.collectAsState().value val progressValue = orchestrator.progress.collectAsState().value
val progressStatusValue = orchestrator.progressState.collectAsState().value val progressStatusValue = orchestrator.progressState.collectAsState().value
Box( Column(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 24.dp), .padding(vertical = 24.dp),
contentAlignment = Alignment.Center, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box( Box(
modifier = Modifier.size(55.dp), modifier = Modifier.size(55.dp),
@@ -88,14 +90,14 @@ fun UploadProgressIndicator(
val txt = val txt =
when (progressStatusValue) { when (progressStatusValue) {
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(Res.string.uploading_state_ready) is UploadingState.Ready -> stringRes(Res.string.uploading_state_ready)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing) is UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading) is UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing) is UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading) is UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing) is UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(Res.string.uploading_state_finished) is UploadingState.Finished -> stringRes(Res.string.uploading_state_finished)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(Res.string.uploading_state_error) is UploadingState.Error -> stringRes(Res.string.uploading_state_error)
} }
Text( Text(
@@ -105,5 +107,16 @@ fun UploadProgressIndicator(
textAlign = TextAlign.Center, 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_mute_thread
import com.vitorpamplona.amethyst.commons.resources.quick_action_report 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
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_request_deletion_alert_title
import com.vitorpamplona.amethyst.commons.resources.quick_action_share import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link
@@ -436,7 +438,7 @@ fun CardBody(
stringRes(Res.string.quick_action_delete), stringRes(Res.string.quick_action_delete),
) { ) {
if (accountViewModel.account.settings.hideDeleteRequestDialog) { if (accountViewModel.account.settings.hideDeleteRequestDialog) {
accountViewModel.delete(note) accountViewModel.deleteOwn(note)
onDismiss() onDismiss()
} else { } else {
showDeleteAlertDialog.value = true 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 @Composable
fun DeleteAlertDialog( fun DeleteAlertDialog(
note: Note, note: Note,
@@ -587,15 +604,15 @@ fun DeleteAlertDialog(
) { ) {
QuickActionAlertDialog( QuickActionAlertDialog(
title = stringRes(Res.string.quick_action_request_deletion_alert_title), 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, buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
onClickDoOnce = { onClickDoOnce = {
accountViewModel.delete(note) accountViewModel.deleteOwn(note)
onDismiss() onDismiss()
}, },
onClickDontShowAgain = { onClickDontShowAgain = {
accountViewModel.delete(note) accountViewModel.deleteOwn(note)
accountViewModel.account.settings.setHideDeleteRequestDialog() accountViewModel.account.settings.setHideDeleteRequestDialog()
onDismiss() onDismiss()
}, },
@@ -427,7 +427,7 @@ fun ImageVideoDescription(
} }
} }
if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) { if (uris.hasVideo() && mediaQualitySlider != 3) {
SettingSwitchItem( SettingSwitchItem(
title = Res.string.video_codec_h265_label, title = Res.string.video_codec_h265_label,
description = Res.string.video_codec_h265_description, 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.Res
import com.vitorpamplona.amethyst.commons.resources.note_actions_dialog_title 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_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.quick_action_request_deletion_alert_title
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable 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.actions.EditPostView
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog 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.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog 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 val performDelete = { accountViewModel.deleteOwn(note) }
// 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)
}
}
if (deleteConfirmationShowing) { if (deleteConfirmationShowing) {
QuickActionAlertDialog( QuickActionAlertDialog(
title = stringRes(Res.string.quick_action_request_deletion_alert_title), 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, buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
onClickDoOnce = { onClickDoOnce = {
@@ -159,14 +159,15 @@ fun noteActionSections(
}, },
) )
} }
}
add( // Your own posts: there is no "author" to follow or file into a list.
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) { add(
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) } NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) {
handlers.onDismiss() note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
}, handlers.onDismiss()
) },
)
}
} }
// When the rendered note was translated, Copy Text opens a chooser (Copy // When the rendered note was translated, Copy Text opens a chooser (Copy
@@ -937,7 +937,10 @@ open class CommentPostViewModel :
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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() { override fun onMessageChanged() {
@@ -596,6 +596,15 @@ class AccountViewModel(
reactToOrDelete(note, reaction) 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 * 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 * gift-wrapped NIP-09 deletion to the same participants. A public deletion
@@ -2464,6 +2473,23 @@ class AccountViewModel(
deliverMarmotGroupMessage(nostrGroupId, innerEvent) 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 * 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. * 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 fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null
/** True when this account has somewhere to upload a group's encrypted media. */ /** True when this account has somewhere to upload a group's encrypted media. */
fun hasBlossomServers(): Boolean = fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty()
account.blossomServers.flow.value
.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) { suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) {
account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId)
@@ -2706,6 +2739,9 @@ class AccountViewModel(
relays.toList(), 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() { override fun onCleared() {
@@ -55,6 +55,9 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException 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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -394,11 +397,17 @@ class GiftWrapEventHandler(
private suspend fun processMarmotWelcomeFlow( private suspend fun processMarmotWelcomeFlow(
innerEvent: Event, innerEvent: Event,
account: Account, account: Account,
attempt: Int = 0,
) { ) {
val manager = account.marmotManager ?: return val manager = account.marmotManager ?: return
if (innerEvent !is WelcomeEvent) { if (innerEvent !is WelcomeEvent) {
return 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. // "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. // nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead.
@@ -407,12 +416,14 @@ private suspend fun processMarmotWelcomeFlow(
when (result) { when (result) {
is WelcomeResult.Joined -> { is WelcomeResult.Joined -> {
manager.markTerminallyIngested(innerEvent.id)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}" "processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}"
} }
// Sync MIP-01 metadata from group extensions to chatroom // Sync MIP-01 metadata from group extensions to chatroom
val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId)
chatroom.awaitingReinvite.value = false
manager.syncMetadataTo(result.nostrGroupId, chatroom) manager.syncMetadataTo(result.nostrGroupId, chatroom)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " + "processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " +
@@ -437,6 +448,7 @@ private suspend fun processMarmotWelcomeFlow(
} }
is WelcomeResult.AlreadyJoined -> { is WelcomeResult.AlreadyJoined -> {
manager.markTerminallyIngested(innerEvent.id)
// Benign replay of a gift-wrapped Welcome (kind:1059) we already // Benign replay of a gift-wrapped Welcome (kind:1059) we already
// processed in a prior session — the relay is just re-delivering // processed in a prior session — the relay is just re-delivering
// it after app restart. Log at DEBUG, not WARN. // it after app restart. Log at DEBUG, not WARN.
@@ -446,11 +458,42 @@ private suspend fun processMarmotWelcomeFlow(
} }
is WelcomeResult.Error -> { 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( class SealEventHandler(
private val account: Account, private val account: Account,
private val cache: LocalCache, private val cache: LocalCache,
@@ -529,7 +572,13 @@ class SealEventHandler(
cache.copyRelaysFromTo(publicNote, rumorId) cache.copyRelaysFromTo(publicNote, rumorId)
val innerRumorNote = cache.getOrCreateNote(rumorId) val innerRumorNote = cache.getOrCreateNote(rumorId)
innerRumorNote.event?.let { innerRumor -> 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) { when (result) {
is GroupEventResult.ApplicationMessage -> { 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 // Parse the inner event JSON and index it
val innerEvent = Event.fromJson(result.innerEventJson) val innerEvent = Event.fromJson(result.innerEventJson)
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
@@ -723,6 +776,7 @@ class GroupEventHandler(
// peer-sent kind:1210 is dropped inside addMessage — see // peer-sent kind:1210 is dropped inside addMessage — see
// `MarmotGroupList.isDisplayableFeedMessage`. // `MarmotGroupList.isDisplayableFeedMessage`.
account.marmotGroupList.addMessage(result.groupId, innerNote) account.marmotGroupList.addMessage(result.groupId, innerNote)
account.marmot.applyMarmotAdminRemoval(result.groupId, innerEvent)
// Traffic is the natural clock for disappearing messages: a // Traffic is the natural clock for disappearing messages: a
// group being read is a group whose expired messages should // group being read is a group whose expired messages should
@@ -827,6 +881,14 @@ class GroupEventHandler(
Log.d("MarmotDbg") { Log.d("MarmotDbg") {
"GroupEventHandler.add: ProposalStaged group=${result.groupId.take(8)}… senderLeaf=${result.senderLeafIndex}" "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 -> { is GroupEventResult.AppMessageOnCandidateBranch -> {
@@ -853,7 +915,12 @@ class GroupEventHandler(
} }
is GroupEventResult.Error -> { 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) { } 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.more_options
import com.vitorpamplona.amethyst.commons.resources.no_wallet_found 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_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.quick_action_request_deletion_alert_title
import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message
import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_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.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.RenderReaction import com.vitorpamplona.amethyst.ui.note.RenderReaction
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid 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.AddHashtagLabelDialog
import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
@@ -146,20 +146,12 @@ fun ChatMessageActionSheet(
var concordBanConfirming by remember { mutableStateOf(false) } var concordBanConfirming by remember { mutableStateOf(false) }
var showDeliveryDialog by remember { mutableStateOf(false) } var showDeliveryDialog by remember { mutableStateOf(false) }
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped val performDelete = { accountViewModel.deleteOwn(note) }
// 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)
}
}
if (deleteConfirmationShowing) { if (deleteConfirmationShowing) {
QuickActionAlertDialog( QuickActionAlertDialog(
title = stringRes(Res.string.quick_action_request_deletion_alert_title), 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, buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn), buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
onClickDoOnce = { onClickDoOnce = {
@@ -253,7 +245,8 @@ fun ChatMessageActionSheet(
isPrivateBookmarkNote = false, isPrivateBookmarkNote = false,
isPublicBookmarkNote = false, isPublicBookmarkNote = false,
isPinnedNote = 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, isSensitive = false,
showSensitiveContent = null, showSensitiveContent = null,
), ),
@@ -296,7 +289,15 @@ fun ChatMessageActionSheet(
note.event is ChatEvent && note.event is ChatEvent &&
isMine && isMine &&
note.inGatherers?.any { it is ConcordChannel } == true 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() SectionDivider()
TileRow { TileRow {
val label = if (canEditBuzz) Res.string.buzz_edit_message else Res.string.edit_message 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 // 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. // usual "hide the name on my own bubbles" shortcut is opt-out there.
val showSelfAuthorName = LocalChatShowSelfAuthorName.current val showSelfAuthorName = LocalChatShowSelfAuthorName.current
val isOneOnOne = LocalChatIsOneOnOne.current
val drawAuthorInfo by val drawAuthorInfo by
remember(note, isLoggedInUser, showSelfAuthorName) { remember(note, isLoggedInUser, showSelfAuthorName, isOneOnOne) {
derivedStateOf { derivedStateOf {
val noteEvent = note.event val noteEvent = note.event
when { when {
@@ -251,6 +252,9 @@ fun NormalChatNote(
// which never draws the user's own author info). // which never draws the user's own author info).
isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent
// The screen knows it's just the two of you.
isOneOnOne -> false
// never shows the user's pictures // never shows the user's pictures
noteEvent is EncryptedDmEvent -> false noteEvent is EncryptedDmEvent -> false
@@ -505,6 +509,14 @@ val LocalChatDisplayNameResolver = compositionLocalOf<((Note) -> String?)?> { nu
*/ */
val LocalChatShowSelfAuthorName = compositionLocalOf { false } 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 * 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 — * 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Composable @Composable
fun CreateGroupScreen( fun CreateGroupScreen(
@@ -136,6 +138,17 @@ fun CreateGroupScreen(
description = groupDescription.trim(), description = groupDescription.trim(),
icon = iconChange, 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) nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class)
} catch (e: Exception) { } catch (e: Exception) {
isCreating = false isCreating = false
@@ -71,7 +71,7 @@ fun MarmotGroupChatScreen(
val displayName by chatroom.displayName.collectAsStateWithLifecycle() val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() val memberCount by chatroom.memberCount.collectAsStateWithLifecycle()
val members by chatroom.members.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( DisappearingScaffold(
isInvertedLayout = true, isInvertedLayout = true,
@@ -28,10 +28,13 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf 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.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.commons.resources.Res 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_disbanding
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving 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_composer_removed
import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name 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_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.resources.reply_here
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.loadStringRes 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.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader 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.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState 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.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -157,18 +168,24 @@ fun MarmotGroupChatView(
.fillMaxHeight() .fillMaxHeight()
.weight(1f, true), .weight(1f, true),
) { ) {
RefreshingChatroomFeedView( // Two members is a 1:1 chat: drop the other person's name and picture from every
feedContentState = feedViewModel.feedState, // bubble, as a NIP-17 conversation does. Zero means not loaded yet, so keep them.
accountViewModel = accountViewModel, val memberCount by chatroom.memberCount.collectAsStateWithLifecycle()
nav = nav, CompositionLocalProvider(LocalChatIsOneOnOne provides (memberCount in 1..2)) {
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId), RefreshingChatroomFeedView(
onWantsToReply = { note -> newMessageModel.reply(note) }, feedContentState = feedViewModel.feedState,
onWantsToEditDraft = { }, accountViewModel = accountViewModel,
// kind:1210 rows sit in the conversation in order but are nav = nav,
// group-state captions rather than messages, so they get their routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
// own centered style instead of a bubble. onWantsToReply = { note -> newMessageModel.reply(note) },
rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) }, 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) Spacer(modifier = DoubleVertSpacer)
@@ -180,10 +197,17 @@ fun MarmotGroupChatView(
// stays readable either way, which is the point of a gate that is not // stays readable either way, which is the point of a gate that is not
// a terminal state. // a terminal state.
val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle() val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle()
val isOutOfSync by chatroom.isOutOfSync.collectAsStateWithLifecycle()
val awaitingReinvite by chatroom.awaitingReinvite.collectAsStateWithLifecycle()
val gate = outboundGate val gate = outboundGate
if (gate != null) { if (awaitingReinvite) {
MarmotGroupNoticeRow(stringRes(Res.string.marmot_awaiting_reinvite))
} else if (gate != null) {
MarmotGroupClosedComposer(gate) MarmotGroupClosedComposer(gate)
} else { } else {
if (isOutOfSync) {
MarmotOutOfSyncBanner(nostrGroupId, accountViewModel)
}
MarmotGroupMessageComposer( MarmotGroupMessageComposer(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
newMessageModel = newMessageModel, newMessageModel = newMessageModel,
@@ -235,6 +259,10 @@ fun MarmotGroupMessageComposer(
} }
} }
newMessageModel.editingMessage.value?.let {
EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
}
Column(modifier = EditFieldModifier) { Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let { newMessageModel.userSuggestions?.let {
ShowUserSuggestionList( 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. * 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.LEAVING -> stringRes(Res.string.marmot_group_composer_leaving)
LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed) LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed)
} }
MarmotGroupNoticeRow(message)
}
@Composable
private fun MarmotGroupNoticeRow(message: String) {
Row( Row(
modifier = EditFieldModifier.fillMaxWidth(), modifier = EditFieldModifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center, 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_grant_admin_title
import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanded_toast 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_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_group_info_title
import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required
import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group
@@ -272,7 +271,13 @@ fun MarmotGroupInfoScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( 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, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, 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.chats.ui.ChatUnreadBadge
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.navigation.Route 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_groups_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations 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_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
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests 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.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp 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.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -297,7 +287,7 @@ fun MarmotGroupListItem(
) { ) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle() val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val members by chatroom.members.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 newestMessage = chatroom.newestMessage
val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle() val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle()
@@ -307,53 +297,7 @@ fun MarmotGroupListItem(
// to ~100 entries, so counting per recomposition is cheap. // to ~100 entries, so counting per recomposition is cheap.
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime } 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 val previewText = marmotGroupPreviewText(newestMessage, accountViewModel)
// 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
}
Row( Row(
modifier = 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 androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User 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.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
@@ -59,6 +60,9 @@ open class MarmotNewMessageViewModel : ViewModel() {
val message = TextFieldState() val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null) 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 uploadState by mutableStateOf<ChatFileUploadState?>(null)
var userSuggestions: UserSuggestionState? = null var userSuggestions: UserSuggestionState? = null
@@ -83,11 +87,28 @@ open class MarmotNewMessageViewModel : ViewModel() {
this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId) this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
this.message.clearText() this.message.clearText()
this.replyTo.value = null this.replyTo.value = null
this.editingMessage.value = null
} }
} }
fun reply(note: Note) { fun reply(note: Note) {
replyTo.value = 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() { fun clearReply() {
@@ -137,6 +158,15 @@ open class MarmotNewMessageViewModel : ViewModel() {
val text = message.text.toString().trim() val text = message.text.toString().trim()
if (text.isEmpty()) return 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 // Capture id+pubKey snapshot before suspending so a slow send
// doesn't race a user-cleared reply state. // doesn't race a user-cleared reply state.
val parentEvent = replyTo.value?.event 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back 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_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_send_image_title
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one 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.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState 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.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress 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 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. // edit on the channel plane), with an X to abandon the edit and clear the field.
newMessageModel.editingMessage.value?.let { newMessageModel.editingMessage.value?.let {
Row( EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
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),
)
}
}
} }
Column(modifier = EditFieldModifier) { Column(modifier = EditFieldModifier) {
@@ -133,7 +133,9 @@ open class ConcordNewMessageViewModel : ViewModel() {
fun reply(note: Note) { fun reply(note: Note) {
replyTo.value = note replyTo.value = note
replyMode.value = ReplyMode.INLINE 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. */ /** 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.leave
import com.vitorpamplona.amethyst.commons.resources.loading_feed import com.vitorpamplona.amethyst.commons.resources.loading_feed
import com.vitorpamplona.amethyst.commons.resources.marmot_group 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.mute_notifications
import com.vitorpamplona.amethyst.commons.resources.muted_chat_content_description import com.vitorpamplona.amethyst.commons.resources.muted_chat_content_description
import com.vitorpamplona.amethyst.commons.resources.pin_conversation 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.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex 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.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.marmotGroup.rememberMarmotGroupAvatarUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription 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 relays by chatroom.relays.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
val author = lastMessage.author val members by chatroom.members.collectAsStateWithLifecycle()
val noteEvent = lastMessage.event val otherMembers = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" 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 // 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 // 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 = val channelPicture =
if (avatarUrl != null || image != null) { if (avatarUrl != null || image != null) {
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
@@ -529,16 +552,6 @@ private fun MarmotGroupRoomCompose(
loadMarmotRelayIcon(relays) 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( ChannelName(
channelIdHex = chatroom.nostrGroupId, channelIdHex = chatroom.nostrGroupId,
channelPicture = channelPicture, channelPicture = channelPicture,
@@ -78,7 +78,10 @@ class ChatFileUploadState(
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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) { 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() { override fun onMessageChanged() {
@@ -514,7 +514,10 @@ open class NewProductViewModel :
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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() { override fun onMessageChanged() {
@@ -1753,7 +1753,10 @@ open class ShortNotePostViewModel :
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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() { override fun onMessageChanged() {
@@ -401,11 +401,16 @@ private fun EncryptedKeyCard(
var encrypted by remember { mutableStateOf<String?>(null) } var encrypted by remember { mutableStateOf<String?>(null) }
var showQr by remember { mutableStateOf(false) } var showQr by remember { mutableStateOf(false) }
// Drop the result while in the background. A password not yet used is kept so switching to // Drop the result and the typed password while in the background, so neither sits in memory
// a password manager to fetch it doesn't wipe the fields; a used one is cleared on success. // (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) { LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
encrypted = null encrypted = null
showQr = false showQr = false
password = ""
repeated = ""
showPassword = false
} }
// A typo in the password makes the backup permanently useless, so it must be typed twice. // 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() { fun encrypt() {
if (!canEncrypt) return 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 { gate.withAccess {
val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess
val currentPassword = password
working = true working = true
// NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread.
scope.launch { scope.launch {
@@ -557,7 +557,10 @@ class NewPublicMessageViewModel :
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { 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() { override fun onMessageChanged() {
@@ -185,9 +185,9 @@ fun RelayStatusRow(
stringRes(Res.string.errors), stringRes(Res.string.errors),
modifier = Size15Modifier, modifier = Size15Modifier,
tint = tint =
if (successRate < 0.1) { if (successRate < 10) {
MaterialTheme.colorScheme.redColorOnSecondSurface MaterialTheme.colorScheme.redColorOnSecondSurface
} else if (successRate < 0.60) { } else if (successRate < 60) {
MaterialTheme.colorScheme.warningColor MaterialTheme.colorScheme.warningColor
} else { } else {
MaterialTheme.colorScheme.allGoodColor MaterialTheme.colorScheme.allGoodColor
@@ -274,9 +274,14 @@ private fun PasswordField(loginViewModel: LoginViewModel) {
onGo = loginViewModel::login, onGo = loginViewModel::login,
) )
LaunchedEffect(Unit) { // Only once the key is complete: a pasted ncryptsec jumps straight to the password,
delay(300) // one being typed keeps its focus until the last character.
passwordFocusRequester.requestFocus() 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.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
@Stable @Stable
class LoginViewModel : ViewModel() { class LoginViewModel : ViewModel() {
@@ -62,6 +63,20 @@ class LoginViewModel : ViewModel() {
Bech32Transcription.normalize(key.text).startsWith("ncryptsec1") 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) var isFirstLogin by mutableStateOf(false)
fun init(accountSessionManager: AccountSessionManager) { 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 // A deletion is not its own row either. The retracted body is
// blanked rather than the row dropped, so a harness (or a reader // blanked rather than the row dropped, so a harness (or a reader
// paging back) can tell "retracted" from "never arrived". // 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 // Pinned at persist time from the retention of the epoch that
// DELIVERED each message, so it is the message's own expiry and not // DELIVERED each message, so it is the message's own expiry and not
// a recomputation against whatever the group's setting is now. // 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 Every test records a pass/fail/skip result into a tab-separated log, and the
summary is printed at the end of the run. 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 > 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 > 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 > 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 pure loopback and isn't matched by that filter. Override with
`--host 127.0.0.5` etc. if `127.0.0.2` is taken. `--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 **Note:** dm-05 validates the kind:15 wire format via reference mode
(caller supplies the URL + AES-GCM key/nonce). The upload-mode variant (caller supplies the URL + AES-GCM key/nonce). The upload-mode variant
(`dm send-file --file PATH --server URL`) needs a local Blossom server (`dm send-file --file PATH --server URL`) needs a local Blossom server
@@ -240,7 +262,7 @@ On the Android side:
## Quick start ## Quick start
```bash ```bash
cd tools/marmot-interop cd cli/tests/marmot
./marmot-interop.sh ./marmot-interop.sh
``` ```
+38
View File
@@ -66,6 +66,35 @@ assert_eq() {
return 1 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) -------------------------------------- # --- embedded relay (amy serve → geode) --------------------------------------
# Every relay-backed harness talks to ONE loopback relay, and that relay is # 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 # `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}" local bind="${RELAY_BIND:-$RELAY_HOST}"
mkdir -p "$relay_home" "$RELAY_DATA/logs" 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; } [[ -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 # 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 ' 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 # npub or hex pubkey of one member/admin entry. MDK names the field per
# collection: members carry `member_id`, admins carry `admin_id`. # collection: members carry `member_id`, admins carry `admin_id`.
jq_member_ids() { jq_member_ids() {
@@ -140,6 +140,8 @@ source "$TESTS_DIR/lib.sh"
source "$SCRIPT_DIR/setup.sh" source "$SCRIPT_DIR/setup.sh"
# shellcheck source=../headless/helpers.sh # shellcheck source=../headless/helpers.sh
source "$TESTS_DIR/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 # shellcheck source=tests-create.sh
source "$SCRIPT_DIR/tests-create.sh" source "$SCRIPT_DIR/tests-create.sh"
# shellcheck source=tests-manage.sh # shellcheck source=tests-manage.sh
@@ -212,6 +214,9 @@ ALL_TESTS=(
test_29_disband_amy_to_wn test_29_disband_amy_to_wn
test_30_wn_commit_after_app_data_update test_30_wn_commit_after_app_data_update
test_31_reaction_materializes_on_wn 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 # --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" source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve) # shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
source "$TESTS_DIR/headless/helpers.sh" source "$TESTS_DIR/headless/helpers.sh"
B_SOCKET="$(short_socket_path "$B_SOCKET")"
C_SOCKET="$(short_socket_path "$C_SOCKET")"
# --- preflight --------------------------------------------------------------- # --- preflight ---------------------------------------------------------------
preflight() { preflight() {
+25 -15
View File
@@ -17,26 +17,35 @@ preflight() {
info "$cmd: $(command -v "$cmd")" info "$cmd: $(command -v "$cmd")"
done 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 # 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 # 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 # 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 # 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. # from Gradle's cache so only the still-missing artifacts get re-fetched.
if [[ ! -x "$AMY_BIN" ]]; then #
if [[ "$NO_BUILD" -eq 1 ]]; then # Build even when the binary exists: an old install/amy from another branch
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1 # tests yesterday's code and fails in misleading ways (a stale amy produced
fi # "UNIQUE constraint" publish rejections that looked like relay bugs). Gradle
local attempt max_attempts=4 # 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 for attempt in $(seq 1 $max_attempts); do
step "building :cli:installDist (attempt $attempt/$max_attempts)" step "building :cli:installDist (attempt $attempt/$max_attempts)"
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \ ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE"
&& [[ -x "$AMY_BIN" ]]; then # gradle's status, not tee's: a failed build must not fall through to the old binary.
break if [[ "${PIPESTATUS[0]}" -eq 0 && -x "$AMY_BIN" ]]; then
built=1; break
fi fi
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying" [[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
done 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 fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN" info "amy: $AMY_BIN"
@@ -68,17 +77,18 @@ preflight() {
# what users are running", which is the question the harness exists to answer. # 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. # 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 # As of 2026-09-27 android is on a master snapshot after 0.10.4
# 0.9.20 (`2f44f6b6`) — android syncs its bindings on its own cadence and got # (`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 # 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, # drift shows up first; a client that satisfies the newer MDK satisfies the
# since every 0.9.20 rule is still in 0.9.21. Pinning to the laggard would # older one. Pinning to the laggard would test the subset and call it
# test the subset and call it coverage. # coverage.
# #
# Bump it deliberately, by reading those lockfiles again — not by drifting. # 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 # If they agree again, that is the value; if they disagree, take the newer
# and say so here. # 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 [[ "$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null)" != "$MDK_PIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; 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" 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. # C should no longer see the group on its own member view.
local deadline=$(( $(date +%s) + 120 )) removed=0 local deadline=$(( $(date +%s) + 120 )) removed=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \ if wn_self_removed c "$mls_gid"; then
| jq_list members | jq -e --arg p "$C_HEX" \
'select((.member_id // .pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
removed=1; break removed=1; break
fi fi
sleep 3 sleep 3
@@ -180,10 +177,9 @@ test_11_leave_group() {
local deadline=$(( $(date +%s) + 120 )) gone=0 local deadline=$(( $(date +%s) + 120 )) gone=0
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \ local rc=0
| jq_list admins | jq -e --arg p "$A_HEX" \ wn_lists b "$mls_gid" admins "$A_HEX" || rc=$?
'select((.admin_id // .pubkey // .public_key) == $p)' \ if [[ "$rc" -eq 1 ]]; then
>/dev/null 2>&1; then
gone=1; break gone=1; break
fi fi
sleep 3 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 # 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. # 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 \ local listed=0
| jq_list members | jq -e --arg p "$A_HEX" \ wn_lists b "$mls_gid" members "$A_HEX" || listed=$?
'select((.member_id // .pubkey // .public_key) == $p)' \ if [[ "$listed" -eq 1 ]]; then
>/dev/null 2>&1; then
record_result "$id" skip "A not in GROUP_02"; return record_result "$id" skip "A not in GROUP_02"; return
fi fi
@@ -337,3 +332,104 @@ test_31_reaction_materializes_on_wn() {
printf '%s\n' "$tl" >> "$LOG_FILE" printf '%s\n' "$tl" >> "$LOG_FILE"
record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)" 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 record_result "$id" fail "amy has no event id for wn's message"; return
fi 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 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 fi
# The row stays, blanked and flagged: "retracted" and "never arrived" are # The row stays, blanked and flagged: "retracted" and "never arrived" are
@@ -460,7 +467,12 @@ test_27_deletion_wn_to_amy() {
done done
if [[ "$gone" -ne 1 ]]; then 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 fi
if [[ -n "$body" ]]; then if [[ -n "$body" ]]; then
record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return 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 wn_b sync >/dev/null 2>&1 || true
sleep 5 sleep 5
done 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' \ printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \
"$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-<none>}" >>"$LOG_FILE" "$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.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage 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.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner 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.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
@@ -177,6 +180,8 @@ class MarmotManager(
* Restore all Marmot state from persistent storage. * Restore all Marmot state from persistent storage.
* Call once during Account initialization. * Call once during Account initialization.
*/ */
private val desync = MarmotDesyncDetector()
suspend fun restoreAll() { suspend fun restoreAll() {
Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" } Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" }
try { try {
@@ -186,8 +191,13 @@ class MarmotManager(
// syncWithGroupManager fills in default (since = null) entries, // syncWithGroupManager fills in default (since = null) entries,
// so even the first filter set sent to relays skips the // so even the first filter set sent to relays skips the
// already-processed kind:445 backlog. // already-processed kind:445 backlog.
subscriptionSinceFromStoredMessages(activeIds).forEach { (groupId, since) -> newestStoredMessageTimes(activeIds).forEach { (groupId, newest) ->
subscriptionManager.subscribeGroup(groupId, since) // 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) subscriptionManager.syncWithGroupManager(activeIds)
// Seed convergence with each restored state. A commit that arrives // Seed convergence with each restored state. A commit that arrives
@@ -204,7 +214,8 @@ class MarmotManager(
keyPackageRotationManager.restoreFromStore() keyPackageRotationManager.restoreFromStore()
ingestDedupStore?.loadAll()?.let { marks -> ingestDedupStore?.loadAll()?.let { marks ->
terminallyIngestedMutex.withLock { terminallyIngested.addAll(marks) } terminallyIngestedMutex.withLock { terminallyIngested.addAll(marks) }
Unit val created = activeIds.filter { createdLocallyMarker(it) in marks }
createdLocallyLock.withLock { createdLocally.addAll(created) }
} }
retryPendingPublishObligations() retryPendingPublishObligations()
Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" } Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" }
@@ -221,6 +232,23 @@ class MarmotManager(
private val terminallyIngested = mutableSetOf<HexKey>() private val terminallyIngested = mutableSetOf<HexKey>()
private val terminallyIngestedMutex = Mutex() 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 isTerminallyIngested(eventId: HexKey): Boolean = terminallyIngestedMutex.withLock { eventId in terminallyIngested }
suspend fun markTerminallyIngested(eventId: HexKey) { suspend fun markTerminallyIngested(eventId: HexKey) {
@@ -391,7 +419,7 @@ class MarmotManager(
* are still fetched; replays inside the window are deduplicated by the * are still fetched; replays inside the window are deduplicated by the
* message store and by note identity in the chatroom. * 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() if (messageStore == null) return emptyMap()
val result = mutableMapOf<HexKey, Long>() val result = mutableMapOf<HexKey, Long>()
for (groupId in groupIds) { for (groupId in groupIds) {
@@ -408,10 +436,7 @@ class MarmotManager(
// and a single future-dated message must not push `since` past // and a single future-dated message must not push `since` past
// the present — that would skip genuinely new events on every // the present — that would skip genuinely new events on every
// restart until a fresher message arrives. // restart until a fresher message arrives.
val newest = minOf(newestStored, TimeUtils.now()) result[groupId] = minOf(newestStored, TimeUtils.now())
if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) {
result[groupId] = newest - GROUP_EVENT_REFETCH_OVERLAP_SEC
}
} }
return result return result
} }
@@ -442,14 +467,17 @@ class MarmotManager(
when (result) { when (result) {
is GroupEventResult.ApplicationMessage -> { is GroupEventResult.ApplicationMessage -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
desync.onDecrypted(result.groupId, groupEvent.createdAt)
} }
is GroupEventResult.CommitProcessed -> { is GroupEventResult.CommitProcessed -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
desync.onDecrypted(result.groupId, groupEvent.createdAt)
} }
is GroupEventResult.ProposalStaged -> { is GroupEventResult.ProposalStaged -> {
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt) subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
desync.onDecrypted(result.groupId, groupEvent.createdAt)
} }
is GroupEventResult.CommitPending, is GroupEventResult.CommitPending,
@@ -457,13 +485,43 @@ class MarmotManager(
is GroupEventResult.UndecryptableOuterLayer, is GroupEventResult.UndecryptableOuterLayer,
is GroupEventResult.AppMessageOnCandidateBranch, is GroupEventResult.AppMessageOnCandidateBranch,
is GroupEventResult.RefusedByLifecycle, 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 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. * Process a WelcomeEvent (kind:444) after NIP-59 unwrapping.
* Returns the result including whether KeyPackage rotation is needed. * Returns the result including whether KeyPackage rotation is needed.
@@ -478,6 +536,8 @@ class MarmotManager(
val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId) val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId)
if (result is WelcomeResult.Joined) { 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 // An authenticated re-join is what clears a departure gate — the
// rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it // rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it
// just as much: a member who left and was invited back holds a gate // 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. */ /** The group's current MLS epoch, which the stream key context binds. */
fun currentEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch 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. * Build a kind:1009 edit that replaces the text of a prior message.
* *
@@ -762,14 +837,7 @@ class MarmotManager(
replacement: String, replacement: String,
persistOwn: Boolean = true, persistOwn: Boolean = true,
): TextMessageBundle { ): TextMessageBundle {
val template = val innerEvent = buildMessageEditRumor(targetEventId, replacement)
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 outbound = buildGroupMessage(nostrGroupId, innerEvent) val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
@@ -1063,7 +1131,9 @@ class MarmotManager(
publishGate.satisfyEmptyObligation(nostrGroupId) publishGate.satisfyEmptyObligation(nostrGroupId)
recordRetentionForCurrentEpoch(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId)
inboundProcessor.trackGroup(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId)
desync.seed(nostrGroupId, TimeUtils.now())
subscriptionManager.subscribeGroup(nostrGroupId) subscriptionManager.subscribeGroup(nostrGroupId)
markCreatedLocally(nostrGroupId)
Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" } Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" }
return nostrGroupId return nostrGroupId
} }
@@ -1105,7 +1175,9 @@ class MarmotManager(
publishGate.satisfyEmptyObligation(nostrGroupId) publishGate.satisfyEmptyObligation(nostrGroupId)
recordRetentionForCurrentEpoch(nostrGroupId) recordRetentionForCurrentEpoch(nostrGroupId)
inboundProcessor.trackGroup(nostrGroupId) inboundProcessor.trackGroup(nostrGroupId)
desync.seed(nostrGroupId, TimeUtils.now())
subscriptionManager.subscribeGroup(nostrGroupId) subscriptionManager.subscribeGroup(nostrGroupId)
markCreatedLocally(nostrGroupId)
return nostrGroupId return nostrGroupId
} }
@@ -1119,6 +1191,11 @@ class MarmotManager(
val confirmed: Boolean, 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 * Prepare a local commit, publish it, and apply it only if publication was
* acknowledged (`protocol-core/publish-lifecycle.md`). * acknowledged (`protocol-core/publish-lifecycle.md`).
@@ -1140,41 +1217,49 @@ class MarmotManager(
ignoringGate: LocalOutboundGate? = null, ignoringGate: LocalOutboundGate? = null,
stage: suspend () -> MlsGroupManager.StagedCommit, stage: suspend () -> MlsGroupManager.StagedCommit,
): CommitPublication { ): CommitPublication {
requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate) // One lock per group from the gate check to the durable obligation. The auto-commit of a
// A commit whose publish went unconfirmed leaves the group in // member's leave runs from ingest while the user may be committing a rename or an add;
// `PendingPublish`, which correctly refuses new commits — but the only // the gate check alone let both pass before either recorded its obligation, and two
// thing that ever resolved it was `restoreAll`, so one dropped socket // commits staged from one epoch fork the group.
// wedged the group until the app was restarted. Retrying this group's val (staged, event, obligation) =
// obligations here makes the next attempt the recovery: republishing commitPreparationLock(nostrGroupId).withLock {
// the same event is safe (a peer deduplicates it by id) and a retry requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate)
// that still fails leaves the group held exactly as before. // A commit whose publish went unconfirmed leaves the group in
if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) { // `PendingPublish`, which correctly refuses new commits — but the only
retryPendingPublishObligations(onlyGroupId = nostrGroupId) // thing that ever resolved it was `restoreAll`, so one dropped socket
} // wedged the group until the app was restarted. Retrying this group's
check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) { // obligations here makes the next attempt the recovery: republishing
"Group $nostrGroupId cannot prepare a local commit " + // the same event is safe (a peer deduplicates it by id) and a retry
"(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})" // 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 staged = stage()
val event = val event =
outboundProcessor.buildCommitEvent( outboundProcessor.buildCommitEvent(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
commitBytes = staged.result.framedCommitBytes, commitBytes = staged.result.framedCommitBytes,
exporterKey = staged.result.preCommitExporterSecret, exporterKey = staged.result.preCommitExporterSecret,
) )
// Durable BEFORE the publish. Publishing first would leave a crash // Durable BEFORE the publish. Publishing first would leave a crash
// window in which peers have accepted a commit this client has no // window in which peers have accepted a commit this client has no
// memory of preparing — and on restart it would generate a // memory of preparing — and on restart it would generate a
// replacement, forking itself at the same epoch. // replacement, forking itself at the same epoch.
val obligation = val obligation =
publishGate.prepare( publishGate.prepare(
groupId = nostrGroupId, groupId = nostrGroupId,
staged = staged, staged = staged,
outboundBytes = event.signedEvent.toJson().encodeToByteArray(), outboundBytes = event.signedEvent.toJson().encodeToByteArray(),
recipientScope = relays.map { it.url }, recipientScope = relays.map { it.url },
) )
Triple(staged, event, obligation)
}
val confirmed = val confirmed =
try { try {
@@ -1548,25 +1633,51 @@ class MarmotManager(
* targets together, since a deletion is only authorized against the message * targets together, since a deletion is only authorized against the message
* it names. * 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 authorOf = HashMap<HexKey, HexKey>(messages.size)
val claims = ArrayList<Pair<HexKey, HexKey>>() val claims = ArrayList<Pair<HexKey, HexKey>>()
val removed = HashSet<HexKey>()
for (event in messages) { for (event in messages) {
if (event.kind == DeletionRequestEvent.KIND) { if (event.kind == DeletionRequestEvent.KIND) {
for (tag in event.tags) { for (tag in event.tags) {
if (tag.size >= 2 && tag[0] == "e") claims.add(tag[1] to event.pubKey) 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 { } else {
authorOf[event.id] = event.pubKey authorOf[event.id] = event.pubKey
} }
} }
val deleted = HashSet<HexKey>(claims.size) val deleted = HashSet<HexKey>(claims.size + removed.size)
for ((targetId, deleter) in claims) { for ((targetId, deleter) in claims) {
if (authorOf[targetId] == deleter) deleted.add(targetId) if (authorOf[targetId] == deleter) deleted.add(targetId)
} }
deleted.addAll(removed.filter { it in authorOf })
return deleted 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, * The slice of canonical group state that kind:1210 rows are derived from,
* or null when this client is not in the group. * or null when this client is not in the group.
@@ -2014,6 +2125,30 @@ class MarmotManager(
}.event }.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 * Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as
* `disbanded` in a Commit every member replays. * `disbanded` in a Commit every member replays.
@@ -2676,6 +2811,12 @@ class MarmotManager(
// events the UI never sees directly — a disband request resolving, a // events the UI never sees directly — a disband request resolving, a
// removal being realized. // removal being realized.
chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId) 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 previousCount = chatroom.members.value.size
val members = memberPubkeys(nostrGroupId) val members = memberPubkeys(nostrGroupId)
chatroom.members.value = members chatroom.members.value = members
@@ -2696,6 +2837,9 @@ class MarmotManager(
*/ */
internal val GROUP_EVENT_REFETCH_OVERLAP_SEC: Long = TimeUtils.ONE_DAY.toLong() 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. * How often the settler re-checks an open pass.
* *
@@ -143,12 +143,24 @@ class MarmotSyncPolicy(
} }
if (filterMap.isEmpty()) return 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 var maxGwSeen = gwSince ?: 0L
val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap() val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap()
var sawGiftWrap = false var sawGiftWrap = false
val sawGroupEvent = mutableSetOf<HexKey>() val sawGroupEvent = mutableSetOf<HexKey>()
val stagedProposals = mutableSetOf<HexKey>()
val waitingOnEpoch = mutableListOf<Event>()
var advancedEpoch = false
for ((relay, event) in events) { for ((relay, event) in events) {
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest — // All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
@@ -161,6 +173,9 @@ class MarmotSyncPolicy(
else -> "" else -> ""
} }
log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail") 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) { when (event.kind) {
GiftWrapEvent.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) { if (sawGiftWrap && maxGwSeen > 0) {
cursors.giftWrapSince = maxGwSeen cursors.giftWrapSince = maxGwSeen
} }
@@ -217,3 +253,11 @@ class MarmotSyncPolicy(
const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60 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) 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`). * True once the group carries the `encrypted-media-v2` policy (`0x800b`).
* *
@@ -38,6 +38,12 @@ class MarmotGroupList(
private val noteToGroupIndex = LargeCache<HexKey, HexKey>() 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) private val _groupListChanges = MutableSharedFlow<HexKey>(0, 20, BufferOverflow.DROP_OLDEST)
val groupListChanges = _groupListChanges val groupListChanges = _groupListChanges
@@ -48,6 +54,7 @@ class MarmotGroupList(
msg: Note, msg: Note,
) { ) {
if (!isDisplayableFeedMessage(msg)) return if (!isDisplayableFeedMessage(msg)) return
if (adminRemovedIds.get(msg.idHex) == nostrGroupId) return
val chatroom = getOrCreateGroup(nostrGroupId) val chatroom = getOrCreateGroup(nostrGroupId)
if (chatroom.addMessageSync(msg)) { if (chatroom.addMessageSync(msg)) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } 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 * 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 * message set and the note→group index: LocalCache holds notes weakly, so
@@ -146,7 +166,9 @@ class MarmotGroupList(
* authorship rule below. * authorship rule below.
*/ */
private fun isDisplayableFeedMessage(msg: Note): Boolean { 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) if (kind == MARMOT_INNER_KIND_SYSTEM_ROW) return isOwnDerivedSystemRow(msg)
return kind !in NON_CHAT_INNER_KINDS return kind !in NON_CHAT_INNER_KINDS
} }
@@ -175,6 +197,7 @@ class MarmotGroupList(
companion object { companion object {
private const val MARMOT_INNER_KIND_DELETION = 5 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_REACTION = 7
private const val MARMOT_INNER_KIND_EDIT = 1009 private const val MARMOT_INNER_KIND_EDIT = 1009
private const val MARMOT_INNER_KIND_STREAM_START = 1200 private const val MARMOT_INNER_KIND_STREAM_START = 1200
@@ -190,6 +213,7 @@ class MarmotGroupList(
private val NON_CHAT_INNER_KINDS = private val NON_CHAT_INNER_KINDS =
setOf( setOf(
MARMOT_INNER_KIND_DELETION, MARMOT_INNER_KIND_DELETION,
MARMOT_INNER_KIND_ADMIN_REMOVAL,
MARMOT_INNER_KIND_REACTION, MARMOT_INNER_KIND_REACTION,
MARMOT_INNER_KIND_EDIT, MARMOT_INNER_KIND_EDIT,
MARMOT_INNER_KIND_STREAM_START, 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") assertTrue(list.getOrCreateGroup(groupId).messages.size == 0, "a payload cannot vouch for itself")
assertFalse(list.groupIdForNote(forged.idHex) == groupId) 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.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlin.coroutines.cancellation.CancellationException
data class UploadResult( data class UploadResult(
val blossom: BlossomUploadResult, val blossom: BlossomUploadResult,
@@ -76,6 +79,8 @@ class UploadOrchestrator(
quality: CompressionQuality? = null, quality: CompressionQuality? = null,
bypassReencode: Boolean = false, bypassReencode: Boolean = false,
preCompressed: File? = null, preCompressed: File? = null,
// Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting].
fallbackServerBaseUrls: List<String> = emptyList(),
): UploadResult { ): UploadResult {
var reencodedTemp: File? = null var reencodedTemp: File? = null
var strippedTemp: File? = null var strippedTemp: File? = null
@@ -133,12 +138,14 @@ class UploadOrchestrator(
// 5. Upload. // 5. Upload.
val result = val result =
client.upload( uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server ->
file = finalFile, client.upload(
contentType = metadata.mimeType, file = finalFile,
serverBaseUrl = serverBaseUrl, contentType = metadata.mimeType,
authHeader = authHeader, serverBaseUrl = server,
) authHeader = authHeader,
)
}
return UploadResult(blossom = result, metadata = metadata) return UploadResult(blossom = result, metadata = metadata)
} finally { } finally {
@@ -171,6 +178,8 @@ class UploadOrchestrator(
// When true it's uploaded with the real media type — less private, but // When true it's uploaded with the real media type — less private, but
// required by strict Blossom servers that reject octet-stream (HTTP 415). // required by strict Blossom servers that reject octet-stream (HTTP 415).
declareRealMimeType: Boolean = false, declareRealMimeType: Boolean = false,
// Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting].
fallbackServerBaseUrls: List<String> = emptyList(),
): EncryptedUploadResult { ): EncryptedUploadResult {
var reencodedTemp: File? = null var reencodedTemp: File? = null
var strippedTemp: File? = null var strippedTemp: File? = null
@@ -230,13 +239,17 @@ class UploadOrchestrator(
// 415) also work, at the cost of leaking the media category. // 415) also work, at the cost of leaking the media category.
val uploadContentType = val uploadContentType =
if (declareRealMimeType) metadata.mimeType else "application/octet-stream" 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 = val result =
client.upload( uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server ->
bytes = encrypted, client.upload(
contentType = uploadContentType, bytes = encrypted,
serverBaseUrl = serverBaseUrl, contentType = uploadContentType,
authHeader = authHeader, serverBaseUrl = server,
) authHeader = authHeader,
)
}
return EncryptedUploadResult( return EncryptedUploadResult(
blossom = result, 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 package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom 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.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
@@ -260,6 +262,81 @@ class MarmotDisbandTest {
assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value) 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 @Test
fun `rejoining a group we left clears the departure gate`() = fun `rejoining a group we left clears the departure gate`() =
runBlocking { runBlocking {
@@ -140,6 +140,48 @@ class MarmotEditsAndSystemRowsTest {
assertTrue(mine.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents())) 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 @Test
fun `one kind 5 retracts every message it names`() = fun `one kind 5 retracts every message it names`() =
runBlocking { 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 @Test
fun `every member applies the commit that evicts the leaver, not just the committer`() = fun `every member applies the commit that evicts the leaver, not just the committer`() =
runBlocking { runBlocking {
@@ -3021,7 +3021,7 @@
<string name="upload_error_voice_message_exception">فشل رفع الصوت: %1$s</string> <string name="upload_error_voice_message_exception">فشل رفع الصوت: %1$s</string>
<string name="upload_error_voice_message_failed">فشل رفع الرسالة الصوتية</string> <string name="upload_error_voice_message_failed">فشل رفع الرسالة الصوتية</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 غير مدعوم للرسائل الصوتية بعد</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">محرك Tor النشط</string>
<string name="use_internal_tor_explainer">استخدم الإصدار الداخلي أو Orbot</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> <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_exception">ভয়েস আপলোড ব্যর্থ হয়েছে: %1$s</string>
<string name="upload_error_voice_message_failed">ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে</string> <string name="upload_error_voice_message_failed">ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 এখনও ভয়েস বার্তার জন্য সমর্থিত নয়</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">সক্রিয় Tor ইঞ্জিন</string>
<string name="use_internal_tor_explainer">অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন</string> <string name="use_internal_tor_explainer">অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই</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_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="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_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="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Otevření</string> <string name="relay_monitor_rtt_open">Otevření</string>
<string name="relay_monitor_rtt_read">Čtení</string> <string name="relay_monitor_rtt_read">Čtení</string>
@@ -3114,7 +3114,7 @@
<item quantity="other">%1$d relayů</item> <item quantity="other">%1$d relayů</item>
</plurals> </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_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> <string name="active_subs_title">Aktivní odběry relayů</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr zatím není přiřazen</item> <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_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_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="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">Aktivní Tor Engine</string>
<string name="use_internal_tor_explainer">Použijte interní verzi nebo Orbot</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> <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_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="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_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="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Öffnen</string> <string name="relay_monitor_rtt_open">Öffnen</string>
<string name="relay_monitor_rtt_read">Lesen</string> <string name="relay_monitor_rtt_read">Lesen</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d Relays</item> <item quantity="other">%1$d Relays</item>
</plurals> </plurals>
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string> <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> <string name="active_subs_title">Aktive Relay-Abonnements</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item> <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_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_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="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">Aktiver Tor-Engine</string>
<string name="use_internal_tor_explainer">Verwende die interne Version oder Orbot</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> <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_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_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="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">Aktiver Tor-Engine</string>
<string name="use_internal_tor_explainer">Verwenden Sie die interne Version oder Orbot</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> <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_exception">Αποτυχία ανεβάσματος φωνητικού: %1$s</string>
<string name="upload_error_voice_message_failed">Αποτυχία ανεβάσματος φωνητικού μηνύματος</string> <string name="upload_error_voice_message_failed">Αποτυχία ανεβάσματος φωνητικού μηνύματος</string>
<string name="upload_error_voice_message_nip95_not_supported">Το NIP-95 δεν υποστηρίζεται ακόμα για φωνητικά μηνύματα</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">Ενεργή μηχανή Tor</string>
<string name="use_internal_tor_explainer">Χρησιμοποιήστε την εσωτερική έκδοση ή το Orbot</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> <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_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_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="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">Aktiva Tor-Motoro</string>
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</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> <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_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_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="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">Aktiva Tor-Motoro</string>
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</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> <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_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_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="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">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</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> <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_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_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="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">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</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> <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_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_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="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">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</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> <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_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_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="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">Motor de Tor activo</string>
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</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> <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_exception">آپلود صوتی ناموفق بود: %1$s</string>
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string> <string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود</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">موتور فعال تور</string>
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string> <string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</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_exception">آپلود صوتی ناموفق بود: %1$s</string>
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string> <string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیام‌های صوتی پشتیبانی نمی‌شود</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">موتور فعال تور</string>
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string> <string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</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_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_failed">Ääniviesti epäonnistui ladata</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ei tue ääniviestiä vielä</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">Aktiivinen Tor-moottori</string>
<string name="use_internal_tor_explainer">Käytä sisäistä versiota tai Orbotia</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> <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_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_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="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">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</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> <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_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_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="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">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</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> <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_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_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="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">Activer le moteur Tor</string>
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</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> <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_exception">ध्वनि आरोहण असफल : %1$s</string>
<string name="upload_error_voice_message_failed">ध्वनि सन्देश आरोहण असफल</string> <string name="upload_error_voice_message_failed">ध्वनि सन्देश आरोहण असफल</string>
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</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">सक्रिय टोर उपकरण</string>
<string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string> <string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</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_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="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_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="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_open">Megnyitás</string>
<string name="relay_monitor_rtt_read">Olvasás</string> <string name="relay_monitor_rtt_read">Olvasás</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d átjátszó</item> <item quantity="other">%1$d átjátszó</item>
</plurals> </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_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> <string name="active_subs_title">Aktív átjátszó-előfizetések</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d szűrő még nincs hozzárendelve</item> <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_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_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="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">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="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> <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_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_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="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">Mesin Tor Aktif</string>
<string name="use_internal_tor_explainer">Gunakan versi internal atau Orbot</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> <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_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_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="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">Motore Tor attivo</string>
<string name="use_internal_tor_explainer">Usa la versione interna o Orbot</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> <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_exception">ボイスのアップロードに失敗しました: %1$s</string>
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string> <string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</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">Tor エンジンを有効化</string>
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</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> <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_exception">ボイスのアップロードに失敗しました: %1$s</string>
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string> <string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</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">Tor エンジンを有効化</string>
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</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> <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_exception">음성 업로드 실패: %1$s</string>
<string name="upload_error_voice_message_failed">음성 메시지 업로드에 실패했습니다</string> <string name="upload_error_voice_message_failed">음성 메시지 업로드에 실패했습니다</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95는 아직 음성 메시지를 지원하지 않습니다</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">내장 Tor 엔진</string>
<string name="use_internal_tor_explainer">내장 버전 또는 Orbot을 사용하세요</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> <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_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_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="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">Aktīvs Tor dzinējs</string>
<string name="use_internal_tor_explainer">Izmantot iekšējo versiju vai Orbot</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> <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_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</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="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">Interne Tor-engine gebruiken</string>
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</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> <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> <string name="road_event_unknown">Weggebeurtenis</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) --> <!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string> <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="podcast_value_for_value">Value-for-Value</string>
<string name="relay_monitor_rtt_open">Openen</string> <string name="relay_monitor_rtt_open">Openen</string>
<string name="relay_monitor_rtt_read">Lezen</string> <string name="relay_monitor_rtt_read">Lezen</string>
@@ -2615,7 +2615,7 @@
<item quantity="other">%1$d relays</item> <item quantity="other">%1$d relays</item>
</plurals> </plurals>
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string> <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> <string name="active_subs_title">Actieve relay-abonnementen</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filter is nog niet toegewezen</item> <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_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</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="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">Interne Tor-engine gebruiken</string>
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</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> <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_exception">Spraakbericht uploaden mislukt: %1$s</string>
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</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="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">Actieve Tor Engine</string>
<string name="use_internal_tor_explainer">Gebruik de interne versie van Orbot</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> <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_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="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_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="podcast_value_for_value">Wartość-za-wartość</string>
<string name="relay_monitor_rtt_open">Otwórz</string> <string name="relay_monitor_rtt_open">Otwórz</string>
<string name="relay_monitor_rtt_read">Odczyt</string> <string name="relay_monitor_rtt_read">Odczyt</string>
@@ -3144,7 +3144,7 @@
<item quantity="other">%1$d transmitery</item> <item quantity="other">%1$d transmitery</item>
</plurals> </plurals>
<string name="active_subs_search_keywords">subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka</string> <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> <string name="active_subs_title">Aktywne subskrypcje transmitera</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filtr nie został jeszcze przypisany</item> <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_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_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="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">Aktywny silnik Tor</string>
<string name="use_internal_tor_explainer">Użyj wersji wewnętrznej lub Orbota</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> <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_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="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_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="podcast_value_for_value">Valor-para-Valor</string>
<string name="relay_monitor_rtt_open">Abrir</string> <string name="relay_monitor_rtt_open">Abrir</string>
<string name="relay_monitor_rtt_read">Leitura</string> <string name="relay_monitor_rtt_read">Leitura</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d Relés</item> <item quantity="other">%1$d Relés</item>
</plurals> </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_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> <string name="active_subs_title">Assinaturas de relay ativas</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filtro ainda não foi atribuído</item> <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_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_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="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">Motor Tor Ativo</string>
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</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> <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_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_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="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">Motor Tor Ativo</string>
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</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> <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_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Неудалось загрузить голосовое сообщение</string> <string name="upload_error_voice_message_failed">Неудалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</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">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string> <string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</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_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string> <string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</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">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</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> <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_exception">Ошибка загрузки голосового сообщения: %1$s</string>
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string> <string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</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">Встроенный движок Tor</string>
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string> <string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</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> <string name="road_event_unknown">Dogodek na cesti</string>
<!-- NIP-CC geocaching (kinds 37516 / 7516) --> <!-- NIP-CC geocaching (kinds 37516 / 7516) -->
<string name="podcast_value_zap_split_hint">Zapi bojo razdeljeni med:</string> <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="podcast_value_for_value">Vrednost za vrednost</string>
<string name="relay_monitor_rtt_open">Odprto</string> <string name="relay_monitor_rtt_open">Odprto</string>
<string name="relay_monitor_rtt_read">Branje</string> <string name="relay_monitor_rtt_read">Branje</string>
@@ -2800,7 +2800,7 @@
<item quantity="other">%1$d relejev</item> <item quantity="other">%1$d relejev</item>
</plurals> </plurals>
<string name="active_subs_search_keywords">naročnine, filtri, releji, zahteve, povezave, zakaj, diagnostika</string> <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> <string name="active_subs_title">Aktivne rele naročnine</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filter še ni dodeljen</item> <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_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_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="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">Aktivni Tor stroj</string>
<string name="use_internal_tor_explainer">Uporabi interno različico ali Orbot</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> <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_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_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="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">Aktivan Tor motor</string>
<string name="use_internal_tor_explainer">Koristite internu verziju ili Orbot</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> <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_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="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_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="podcast_value_for_value">Värde -för-värde</string>
<string name="relay_monitor_rtt_open">Öppna</string> <string name="relay_monitor_rtt_open">Öppna</string>
<string name="relay_monitor_rtt_read">Läs</string> <string name="relay_monitor_rtt_read">Läs</string>
@@ -3088,7 +3088,7 @@
<item quantity="other">%1$d reläer</item> <item quantity="other">%1$d reläer</item>
</plurals> </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_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> <string name="active_subs_title">Aktiva reläprenumerationer</string>
<plurals name="active_subs_untagged"> <plurals name="active_subs_untagged">
<item quantity="one">%1$d filter är inte kopplat ännu</item> <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_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_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="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">Aktiv Tor Engine</string>
<string name="use_internal_tor_explainer">Använd den interna versionen eller Orbot</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> <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_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_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="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">Injini ya Tor Inayofanya Kazi</string>
<string name="use_internal_tor_explainer">Tumia toleo la ndani au Orbot</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> <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_exception">குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s</string>
<string name="upload_error_voice_message_failed">குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது</string> <string name="upload_error_voice_message_failed">குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது</string>
<string name="upload_error_voice_message_nip95_not_supported">குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை</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">செயலில் Tor எஞ்சின்</string>
<string name="use_internal_tor_explainer">உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும்</string> <string name="use_internal_tor_explainer">உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும்</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</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