mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -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(
|
||||||
|
|||||||
+76
-11
@@ -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
|
||||||
|
|||||||
+53
-2
@@ -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
-10
@@ -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()
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -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 = {
|
||||||
|
|||||||
+8
-7
@@ -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
|
||||||
|
|||||||
+4
-1
@@ -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() {
|
||||||
|
|||||||
+39
-3
@@ -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() {
|
||||||
|
|||||||
+70
-3
@@ -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) {
|
||||||
|
|||||||
+14
-13
@@ -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
|
||||||
|
|||||||
+13
-1
@@ -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 —
|
||||||
|
|||||||
+13
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+89
-13
@@ -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,
|
||||||
|
|||||||
+7
-2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-58
@@ -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 =
|
||||||
|
|||||||
+140
@@ -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
@@ -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
|
||||||
|
|||||||
+2
-26
@@ -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) {
|
||||||
|
|||||||
+3
-1
@@ -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. */
|
||||||
|
|||||||
+28
-15
@@ -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,
|
||||||
|
|||||||
+4
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+71
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -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() {
|
||||||
|
|||||||
+4
-1
@@ -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() {
|
||||||
|
|||||||
+4
-1
@@ -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() {
|
||||||
|
|||||||
+10
-3
@@ -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 {
|
||||||
|
|||||||
+4
-1
@@ -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() {
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+8
-3
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+103
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
-52
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
+45
-1
@@ -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
|
||||||
|
|||||||
+6
@@ -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`).
|
||||||
*
|
*
|
||||||
|
|||||||
+25
-1
@@ -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,
|
||||||
|
|||||||
+47
@@ -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
|
||||||
|
}
|
||||||
+34
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
-12
@@ -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!!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -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 {
|
||||||
|
|||||||
+42
@@ -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 {
|
||||||
|
|||||||
+29
@@ -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
Reference in New Issue
Block a user