mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge origin/main into claude/intelligent-babbage-23komv
Three import-only conflicts (MultiOrchestrator, MarmotGroupChatView, MarmotGroupListScreen), resolved as the union of both sides with AccountViewModel at its new commons.viewmodels package. Main's new MarmotGroupPreview is repointed the same way, and DecryptAndIndexProcessor (commonMain) gains the kotlinx.coroutines.IO import main's change needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
@@ -66,6 +66,11 @@ def find_violations(root: Path):
|
||||
)
|
||||
if n:
|
||||
found[path]["raw line break/tab in a value"] += n
|
||||
# Android's printf escape. Compose substitutes only %N$s / %N$d, so `%%`
|
||||
# renders as two percent signs ("100%% uptime").
|
||||
n = sum(m.group(2).count("%%") for m in STRING_TEXT.finditer(text))
|
||||
if n:
|
||||
found[path]["%%"] += n
|
||||
return found
|
||||
|
||||
|
||||
|
||||
+5
-1
@@ -190,6 +190,10 @@ Where:
|
||||
| `<arch>` | `x64`, `arm64` |
|
||||
| `<ext>` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` |
|
||||
|
||||
The AppImage is the exception: `amethyst-desktop-<version>-<x86_64|aarch64>.AppImage`.
|
||||
AppImageHub flags `linux` in an AppImage name (every AppImage is for Linux) and
|
||||
expects the AppImage arch names.
|
||||
|
||||
Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh).
|
||||
Package manager manifests (Homebrew cask, Winget) depend on this exact scheme —
|
||||
any change is a breaking contract.
|
||||
@@ -199,7 +203,7 @@ Examples:
|
||||
- `amethyst-desktop-1.12.1-macos-x64.dmg`
|
||||
- `amethyst-desktop-1.12.1-macos-arm64.dmg`
|
||||
- `amethyst-desktop-1.12.1-windows-x64.msi`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.AppImage`
|
||||
- `amethyst-desktop-1.12.1-x86_64.AppImage`
|
||||
- `amethyst-desktop-1.12.1-linux-x64.flatpak`
|
||||
|
||||
---
|
||||
|
||||
+6
-1
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -37,7 +40,9 @@ import kotlinx.coroutines.launch
|
||||
class MultiOrchestrator(
|
||||
uris: List<SelectedMedia>,
|
||||
) {
|
||||
private var list: List<SelectedMediaProcessing> = uris.map { SelectedMediaProcessing(it) }
|
||||
// Snapshot state so a removal recomposes the gallery that draws it; as a plain var the
|
||||
// Remove on a failed upload dropped the item from the upload but left it on screen.
|
||||
private var list: List<SelectedMediaProcessing> by mutableStateOf(uris.map { SelectedMediaProcessing(it) })
|
||||
|
||||
@Stable
|
||||
class Result(
|
||||
|
||||
+76
-11
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required
|
||||
@@ -203,6 +204,58 @@ class UploadOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries [selected], then the rest of the servers the picker offered (see
|
||||
* [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they
|
||||
* share one signed upload token: the signer is asked once, and a signer that refused, timed
|
||||
* out or is read-only stops the fallback instead of prompting again per server. Attempts
|
||||
* don't publish their errors; when every server fails, the error shown is the selected
|
||||
* server's, the one the user chose and will recognize.
|
||||
*/
|
||||
private suspend fun uploadBlossomWithFallback(
|
||||
selected: ServerName,
|
||||
account: Account,
|
||||
uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState,
|
||||
): UploadingFinalState {
|
||||
val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value)
|
||||
val auth = SharedUploadAuth()
|
||||
var firstError: UploadingState.Error? = null
|
||||
for (server in order) {
|
||||
when (val result = uploadTo(server.baseUrl, auth)) {
|
||||
is UploadingState.Finished -> return result
|
||||
is UploadingState.Error -> {
|
||||
if (firstError == null) firstError = result
|
||||
if (auth.signerFailed) return result.also { updateState(0.0, it) }
|
||||
Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server")
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstError!!.also { updateState(0.0, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload token for one blob, signed at most once and reused by every server tried.
|
||||
* A signer that returned no token is asked again on the next server; only a token or a
|
||||
* signer failure is kept.
|
||||
*/
|
||||
private class SharedUploadAuth {
|
||||
private var outcome: Result<BlossomAuthorizationEvent>? = null
|
||||
|
||||
var signerFailed = false
|
||||
private set
|
||||
|
||||
suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? {
|
||||
outcome?.let { return it.getOrThrow() }
|
||||
return try {
|
||||
sign()?.also { outcome = Result.success(it) }
|
||||
} catch (e: SignerExceptions) {
|
||||
signerFailed = true
|
||||
outcome = Result.failure(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun uploadBlossom(
|
||||
fileUri: Uri,
|
||||
contentType: String?,
|
||||
@@ -215,6 +268,7 @@ class UploadOrchestrator {
|
||||
account: Account,
|
||||
forcedSigner: NostrSigner?,
|
||||
context: Context,
|
||||
sharedAuth: SharedUploadAuth,
|
||||
): UploadingFinalState {
|
||||
updateState(0.2, UploadingState.Uploading)
|
||||
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
|
||||
@@ -236,12 +290,15 @@ class UploadOrchestrator {
|
||||
// upload path: some servers reject an upload whose auth carries a
|
||||
// `server` tag, and upload-token replay is not the threat scoping
|
||||
// guards against (delete tokens are — those stay scoped).
|
||||
httpAuth =
|
||||
when {
|
||||
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
|
||||
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) }
|
||||
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }
|
||||
},
|
||||
httpAuth = { hash, size, alt ->
|
||||
sharedAuth.get {
|
||||
when {
|
||||
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
|
||||
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
|
||||
else -> account.createBlossomUploadAuth(hash, size, alt)
|
||||
}
|
||||
}
|
||||
},
|
||||
context = context,
|
||||
useMediaEndpoint = useMedia,
|
||||
)
|
||||
@@ -272,13 +329,13 @@ class UploadOrchestrator {
|
||||
|
||||
finalState
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
|
||||
UploadingState.Error(Res.string.login_with_a_private_key_to_be_able_to_upload, emptyArray())
|
||||
} catch (e: BlossomPaymentException) {
|
||||
// BUD-07: the server wants payment before it will store the blob.
|
||||
error(Res.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
|
||||
UploadingState.Error(Res.string.blossom_payment_required, arrayOf(e.payment.reason ?: serverBaseUrl))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error(Res.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
|
||||
UploadingState.Error(Res.string.failed_to_upload_media, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,7 +540,10 @@ class UploadOrchestrator {
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
|
||||
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
|
||||
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
|
||||
ServerType.Blossom ->
|
||||
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
|
||||
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
deleteTempUri(finalUri, uri)
|
||||
@@ -528,7 +588,12 @@ class UploadOrchestrator {
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
||||
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
|
||||
ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
|
||||
// The same encrypted file goes to every server tried, so its key, nonce and
|
||||
// hash stay valid whichever one ends up holding it.
|
||||
ServerType.Blossom ->
|
||||
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
|
||||
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
deleteTempUri(encrypted.uri, uri)
|
||||
|
||||
@@ -330,6 +330,9 @@ open class EditPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,10 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null
|
||||
|
||||
+53
-2
@@ -28,6 +28,7 @@ import android.os.Build
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,11 +39,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -64,6 +67,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.close
|
||||
import com.vitorpamplona.amethyst.commons.resources.remove
|
||||
import com.vitorpamplona.amethyst.commons.resources.upload_error_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.uploading_state_compressing
|
||||
import com.vitorpamplona.amethyst.commons.resources.uploading_state_downloading
|
||||
import com.vitorpamplona.amethyst.commons.resources.uploading_state_error
|
||||
@@ -216,10 +222,55 @@ fun OrchestratorOverlay(
|
||||
val progress by orchestrator.progress.collectAsState()
|
||||
val progressState by orchestrator.progressState.collectAsState()
|
||||
|
||||
if (progressState is UploadingState.Ready) {
|
||||
val state = progressState
|
||||
if (state is UploadingState.Ready) {
|
||||
DeleteButton(onDelete)
|
||||
} else if (state is UploadingState.Error) {
|
||||
UploadErrorBadge(progress, state, onDelete)
|
||||
} else {
|
||||
UploadingState(progress, progressState)
|
||||
UploadingState(progress, state)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed upload: the badge alone has room for one word, so tapping it opens the reason
|
||||
* (server refused the file type, payment required, no connection, ...). Without it the
|
||||
* user had "Error" and nothing to act on, and no way to drop the failed attachment.
|
||||
*/
|
||||
@Composable
|
||||
private fun UploadErrorBadge(
|
||||
progress: Double,
|
||||
state: UploadingState.Error,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var showDetails by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { showDetails = true },
|
||||
) {
|
||||
UploadingState(progress, state)
|
||||
}
|
||||
|
||||
if (showDetails) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDetails = false },
|
||||
title = { Text(stringRes(Res.string.upload_error_title)) },
|
||||
text = { Text(stringRes(state.errorResource, *state.params)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDetails = false }) { Text(stringRes(Res.string.close)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDetails = false
|
||||
onDelete()
|
||||
},
|
||||
) { Text(stringRes(Res.string.remove)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
|
||||
@Composable
|
||||
fun UploadProgressIndicator(
|
||||
@@ -60,12 +62,12 @@ fun UploadProgressIndicator(
|
||||
val progressValue = orchestrator.progress.collectAsState().value
|
||||
val progressStatusValue = orchestrator.progressState.collectAsState().value
|
||||
|
||||
Box(
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(55.dp),
|
||||
@@ -88,14 +90,14 @@ fun UploadProgressIndicator(
|
||||
|
||||
val txt =
|
||||
when (progressStatusValue) {
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(Res.string.uploading_state_ready)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(Res.string.uploading_state_finished)
|
||||
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(Res.string.uploading_state_error)
|
||||
is UploadingState.Ready -> stringRes(Res.string.uploading_state_ready)
|
||||
is UploadingState.Compressing -> stringRes(Res.string.uploading_state_compressing)
|
||||
is UploadingState.Uploading -> stringRes(Res.string.uploading_state_uploading)
|
||||
is UploadingState.ServerProcessing -> stringRes(Res.string.uploading_state_server_processing)
|
||||
is UploadingState.Downloading -> stringRes(Res.string.uploading_state_downloading)
|
||||
is UploadingState.Hashing -> stringRes(Res.string.uploading_state_hashing)
|
||||
is UploadingState.Finished -> stringRes(Res.string.uploading_state_finished)
|
||||
is UploadingState.Error -> stringRes(Res.string.uploading_state_error)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -105,5 +107,16 @@ fun UploadProgressIndicator(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
// The badge fits one word; the reason is what the user can act on.
|
||||
if (progressStatusValue is UploadingState.Error) {
|
||||
Text(
|
||||
stringRes(progressStatusValue.errorResource, *progressStatusValue.params),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.commons.resources.quick_action_follow
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_mute_thread
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_report
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_group
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body_private
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_share_browser_link
|
||||
@@ -436,7 +438,7 @@ fun CardBody(
|
||||
stringRes(Res.string.quick_action_delete),
|
||||
) {
|
||||
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.deleteOwn(note)
|
||||
onDismiss()
|
||||
} else {
|
||||
showDeleteAlertDialog.value = true
|
||||
@@ -579,6 +581,21 @@ fun NoteQuickActionItem(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a deletion actually does for [note]: a public NIP-09 to relays, a request sent
|
||||
* inside a Marmot group, or a gift-wrapped request to a NIP-17 conversation. The public
|
||||
* wording ("deleted from the relays you are connected to") is wrong for the last two.
|
||||
*/
|
||||
fun deletionRequestBody(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
): StringResource =
|
||||
when {
|
||||
accountViewModel.account.marmot.marmotGroupOf(note) != null -> Res.string.quick_action_request_deletion_alert_body_group
|
||||
note.isPrivateRumor() -> Res.string.quick_action_request_deletion_alert_body_private
|
||||
else -> Res.string.quick_action_request_deletion_alert_body
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteAlertDialog(
|
||||
note: Note,
|
||||
@@ -587,15 +604,15 @@ fun DeleteAlertDialog(
|
||||
) {
|
||||
QuickActionAlertDialog(
|
||||
title = stringRes(Res.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringRes(Res.string.quick_action_request_deletion_alert_body),
|
||||
textContent = stringRes(deletionRequestBody(note, accountViewModel)),
|
||||
buttonIcon = MaterialSymbols.Delete,
|
||||
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.deleteOwn(note)
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.deleteOwn(note)
|
||||
accountViewModel.account.settings.setHideDeleteRequestDialog()
|
||||
onDismiss()
|
||||
},
|
||||
|
||||
+1
-1
@@ -427,7 +427,7 @@ fun ImageVideoDescription(
|
||||
}
|
||||
}
|
||||
|
||||
if (uris.first().media.isVideo() == true && mediaQualitySlider != 3) {
|
||||
if (uris.hasVideo() && mediaQualitySlider != 3) {
|
||||
SettingSwitchItem(
|
||||
title = Res.string.video_codec_h265_label,
|
||||
description = Res.string.video_codec_h265_description,
|
||||
|
||||
@@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.note_actions_dialog_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
|
||||
@@ -48,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.deletionRequestBody
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
|
||||
@@ -163,20 +163,12 @@ fun NoteDropDownMenu(
|
||||
)
|
||||
}
|
||||
|
||||
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped
|
||||
// deletion — a public NIP-09 would e-tag the rumor id onto public relays.
|
||||
val performDelete = {
|
||||
if (note.isPrivateRumor()) {
|
||||
accountViewModel.deletePrivately(note)
|
||||
} else {
|
||||
accountViewModel.delete(note)
|
||||
}
|
||||
}
|
||||
val performDelete = { accountViewModel.deleteOwn(note) }
|
||||
|
||||
if (deleteConfirmationShowing) {
|
||||
QuickActionAlertDialog(
|
||||
title = stringRes(Res.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringRes(Res.string.quick_action_request_deletion_alert_body),
|
||||
textContent = stringRes(deletionRequestBody(note, accountViewModel)),
|
||||
buttonIcon = MaterialSymbols.Delete,
|
||||
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
|
||||
+8
-7
@@ -159,14 +159,15 @@ fun noteActionSections(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) {
|
||||
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
// Your own posts: there is no "author" to follow or file into a list.
|
||||
add(
|
||||
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(Res.string.follow_set_add_author_from_note_action)) {
|
||||
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
|
||||
handlers.onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// When the rendered note was translated, Copy Text opens a chooser (Copy
|
||||
|
||||
+4
-1
@@ -938,7 +938,10 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
|
||||
+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.no_wallet_found
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_delete_dialog_btn
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.quick_action_request_deletion_alert_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_group_pin_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message
|
||||
@@ -95,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
|
||||
import com.vitorpamplona.amethyst.ui.note.deletionRequestBody
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
|
||||
@@ -146,20 +146,12 @@ fun ChatMessageActionSheet(
|
||||
var concordBanConfirming by remember { mutableStateOf(false) }
|
||||
var showDeliveryDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped
|
||||
// deletion — a public NIP-09 would e-tag the rumor id onto public relays.
|
||||
val performDelete = {
|
||||
if (note.isPrivateRumor()) {
|
||||
accountViewModel.deletePrivately(note)
|
||||
} else {
|
||||
accountViewModel.delete(note)
|
||||
}
|
||||
}
|
||||
val performDelete = { accountViewModel.deleteOwn(note) }
|
||||
|
||||
if (deleteConfirmationShowing) {
|
||||
QuickActionAlertDialog(
|
||||
title = stringRes(Res.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringRes(Res.string.quick_action_request_deletion_alert_body),
|
||||
textContent = stringRes(deletionRequestBody(note, accountViewModel)),
|
||||
buttonIcon = MaterialSymbols.Delete,
|
||||
buttonText = stringRes(Res.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
@@ -253,7 +245,8 @@ fun ChatMessageActionSheet(
|
||||
isPrivateBookmarkNote = false,
|
||||
isPublicBookmarkNote = false,
|
||||
isPinnedNote = false,
|
||||
isLoggedUser = false,
|
||||
// Seeded from the note so the first frame doesn't flash author actions on your own message.
|
||||
isLoggedUser = accountViewModel.isLoggedUser(note.author),
|
||||
isSensitive = false,
|
||||
showSensitiveContent = null,
|
||||
),
|
||||
@@ -296,7 +289,15 @@ fun ChatMessageActionSheet(
|
||||
note.event is ChatEvent &&
|
||||
isMine &&
|
||||
note.inGatherers?.any { it is ConcordChannel } == true
|
||||
if (canEditBuzz || canEditConcord) {
|
||||
// Marmot: my own text message in a group -> a kind-1009 edit inside the group. A
|
||||
// media message's content is its locator, so editing it would break the attachment.
|
||||
val canEditMarmot =
|
||||
onWantsToEditChatMessage != null &&
|
||||
note.event is ChatEvent &&
|
||||
isMine &&
|
||||
note.event?.tags?.none { it.isNotEmpty() && it[0] == "imeta" } == true &&
|
||||
accountViewModel.account.marmot.marmotGroupOf(note) != null
|
||||
if (canEditBuzz || canEditConcord || canEditMarmot) {
|
||||
SectionDivider()
|
||||
TileRow {
|
||||
val label = if (canEditBuzz) Res.string.buzz_edit_message else Res.string.edit_message
|
||||
|
||||
+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
|
||||
// usual "hide the name on my own bubbles" shortcut is opt-out there.
|
||||
val showSelfAuthorName = LocalChatShowSelfAuthorName.current
|
||||
val isOneOnOne = LocalChatIsOneOnOne.current
|
||||
|
||||
val drawAuthorInfo by
|
||||
remember(note, isLoggedInUser, showSelfAuthorName) {
|
||||
remember(note, isLoggedInUser, showSelfAuthorName, isOneOnOne) {
|
||||
derivedStateOf {
|
||||
val noteEvent = note.event
|
||||
when {
|
||||
@@ -251,6 +252,9 @@ fun NormalChatNote(
|
||||
// which never draws the user's own author info).
|
||||
isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent
|
||||
|
||||
// The screen knows it's just the two of you.
|
||||
isOneOnOne -> false
|
||||
|
||||
// never shows the user's pictures
|
||||
noteEvent is EncryptedDmEvent -> false
|
||||
|
||||
@@ -505,6 +509,14 @@ val LocalChatDisplayNameResolver = compositionLocalOf<((Note) -> String?)?> { nu
|
||||
*/
|
||||
val LocalChatShowSelfAuthorName = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Whether the conversation is known to be one-on-one even though its messages don't say so.
|
||||
* A NIP-17 message carries its participants ([ChatroomKeyable]) and hides the other person's
|
||||
* name and picture when there is only one; a Marmot group's inner kind:9 carries nothing, so the
|
||||
* screen, which knows the member count, says it here.
|
||||
*/
|
||||
val LocalChatIsOneOnOne = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* The geohash of the location room currently open, or null outside one. Every message in that room
|
||||
* repeats the room's own cell in its `g` tag, so the bubble footer suppresses this one geohash —
|
||||
|
||||
+13
@@ -68,9 +68,11 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.uploadMarmotGroupIcon
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Composable
|
||||
fun CreateGroupScreen(
|
||||
@@ -137,6 +139,17 @@ fun CreateGroupScreen(
|
||||
description = groupDescription.trim(),
|
||||
icon = iconChange,
|
||||
)
|
||||
// Commit the encrypted-media policy while we are the only member. White Noise
|
||||
// (MDK) refuses to send any attachment in a group without one
|
||||
// ("group does not require encrypted media"), and joiners learn it from the
|
||||
// Welcome, so nobody has to apply a later commit to be able to share media.
|
||||
// Best-effort: a group without it still works for text and legacy MIP-04.
|
||||
try {
|
||||
accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("CreateGroupScreen") { "Could not enable encrypted media for $nostrGroupId: ${e.message}" }
|
||||
}
|
||||
nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class)
|
||||
} catch (e: Exception) {
|
||||
isCreating = false
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ fun MarmotGroupChatScreen(
|
||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
||||
val memberCount by chatroom.memberCount.collectAsStateWithLifecycle()
|
||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||
val memberPubkeys = remember(members) { members.map { it.pubkey } }
|
||||
val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = true,
|
||||
|
||||
+89
-13
@@ -28,10 +28,13 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -52,11 +55,17 @@ import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_awaiting_reinvite
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_disbanding
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_leaving
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_composer_removed
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_default_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_not_a_member
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset
|
||||
import com.vitorpamplona.amethyst.commons.resources.reply_here
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
@@ -75,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalChatIsOneOnOne
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileSender
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader
|
||||
@@ -82,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.Marm
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
|
||||
import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -157,18 +168,24 @@ fun MarmotGroupChatView(
|
||||
.fillMaxHeight()
|
||||
.weight(1f, true),
|
||||
) {
|
||||
RefreshingChatroomFeedView(
|
||||
feedContentState = feedViewModel.feedState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
|
||||
onWantsToReply = { note -> newMessageModel.reply(note) },
|
||||
onWantsToEditDraft = { },
|
||||
// kind:1210 rows sit in the conversation in order but are
|
||||
// group-state captions rather than messages, so they get their
|
||||
// own centered style instead of a bubble.
|
||||
rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) },
|
||||
)
|
||||
// Two members is a 1:1 chat: drop the other person's name and picture from every
|
||||
// bubble, as a NIP-17 conversation does. Zero means not loaded yet, so keep them.
|
||||
val memberCount by chatroom.memberCount.collectAsStateWithLifecycle()
|
||||
CompositionLocalProvider(LocalChatIsOneOnOne provides (memberCount in 1..2)) {
|
||||
RefreshingChatroomFeedView(
|
||||
feedContentState = feedViewModel.feedState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = marmotGroupLastReadRoute(nostrGroupId),
|
||||
onWantsToReply = { note -> newMessageModel.reply(note) },
|
||||
onWantsToEditDraft = { },
|
||||
onWantsToEditChatMessage = { note -> newMessageModel.editMarmotMessage(note) },
|
||||
// kind:1210 rows sit in the conversation in order but are
|
||||
// group-state captions rather than messages, so they get their
|
||||
// own centered style instead of a bubble.
|
||||
rowRenderer = remember(accountViewModel) { MarmotSystemRowRenderer(accountViewModel) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
@@ -180,10 +197,17 @@ fun MarmotGroupChatView(
|
||||
// stays readable either way, which is the point of a gate that is not
|
||||
// a terminal state.
|
||||
val outboundGate by chatroom.outboundGate.collectAsStateWithLifecycle()
|
||||
val isOutOfSync by chatroom.isOutOfSync.collectAsStateWithLifecycle()
|
||||
val awaitingReinvite by chatroom.awaitingReinvite.collectAsStateWithLifecycle()
|
||||
val gate = outboundGate
|
||||
if (gate != null) {
|
||||
if (awaitingReinvite) {
|
||||
MarmotGroupNoticeRow(stringRes(Res.string.marmot_awaiting_reinvite))
|
||||
} else if (gate != null) {
|
||||
MarmotGroupClosedComposer(gate)
|
||||
} else {
|
||||
if (isOutOfSync) {
|
||||
MarmotOutOfSyncBanner(nostrGroupId, accountViewModel)
|
||||
}
|
||||
MarmotGroupMessageComposer(
|
||||
nostrGroupId = nostrGroupId,
|
||||
newMessageModel = newMessageModel,
|
||||
@@ -235,6 +259,10 @@ fun MarmotGroupMessageComposer(
|
||||
}
|
||||
}
|
||||
|
||||
newMessageModel.editingMessage.value?.let {
|
||||
EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
|
||||
}
|
||||
|
||||
Column(modifier = EditFieldModifier) {
|
||||
newMessageModel.userSuggestions?.let {
|
||||
ShowUserSuggestionList(
|
||||
@@ -378,6 +406,49 @@ private fun MarmotGroupFileUploadDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This device has fallen off the group's epoch chain (MarmotDesyncDetector): nothing the
|
||||
* other members send decrypts here, and it will not heal on its own. Offers the one way
|
||||
* back that exists, dropping the local copy so an admin can add this member again.
|
||||
*/
|
||||
@Composable
|
||||
private fun MarmotOutOfSyncBanner(
|
||||
nostrGroupId: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
var confirming by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) {
|
||||
Text(
|
||||
text = stringRes(Res.string.marmot_out_of_sync_body),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(onClick = { confirming = true }, modifier = Modifier.align(Alignment.End)) {
|
||||
Text(stringRes(Res.string.marmot_out_of_sync_reset))
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirming = false },
|
||||
title = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_title)) },
|
||||
text = { Text(stringRes(Res.string.marmot_out_of_sync_confirm_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
confirming = false
|
||||
accountViewModel.resetOutOfSyncMarmotGroup(nostrGroupId)
|
||||
},
|
||||
) { Text(stringRes(Res.string.marmot_out_of_sync_reset)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirming = false }) { Text(stringRes(Res.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the composer when an outbound gate is up.
|
||||
*
|
||||
@@ -394,6 +465,11 @@ private fun MarmotGroupClosedComposer(gate: LocalOutboundGate) {
|
||||
LocalOutboundGate.LEAVING -> stringRes(Res.string.marmot_group_composer_leaving)
|
||||
LocalOutboundGate.REMOVED -> stringRes(Res.string.marmot_group_composer_removed)
|
||||
}
|
||||
MarmotGroupNoticeRow(message)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarmotGroupNoticeRow(message: String) {
|
||||
Row(
|
||||
modifier = EditFieldModifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
|
||||
+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_group_disbanded_toast
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_disbanding_toast
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_info_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_required
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_leave_group
|
||||
@@ -272,7 +271,13 @@ fun MarmotGroupInfoScreen(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayName ?: stringRes(Res.string.marmot_group_fallback_name, nostrGroupId.take(8)),
|
||||
text =
|
||||
marmotGroupTitle(
|
||||
displayName,
|
||||
remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) },
|
||||
nostrGroupId,
|
||||
accountViewModel,
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
+2
-58
@@ -59,7 +59,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.chats.ui.ChatUnreadBadge
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroupLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
@@ -73,11 +72,6 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests
|
||||
@@ -91,13 +85,9 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -297,7 +287,7 @@ fun MarmotGroupListItem(
|
||||
) {
|
||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||
val memberPubkeys = remember(members) { members.map { it.pubkey } }
|
||||
val memberPubkeys = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }
|
||||
val newestMessage = chatroom.newestMessage
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(groupId)).collectAsStateWithLifecycle()
|
||||
@@ -307,53 +297,7 @@ fun MarmotGroupListItem(
|
||||
// to ~100 entries, so counting per recomposition is cheap.
|
||||
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
|
||||
|
||||
// A preview has to survive the messages that carry no text: a system row
|
||||
// keeps its state in tags and MIP-04 media keeps its in imeta, so both used
|
||||
// to render as a blank second line under the group name.
|
||||
val myPubKey = accountViewModel.account.signer.pubKey
|
||||
val previewEvent = newestMessage?.event
|
||||
val previewBody =
|
||||
when {
|
||||
newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet)
|
||||
previewEvent == null -> stringRes(Res.string.marmot_preview_no_text)
|
||||
previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated)
|
||||
// Text first: an attachment usually carries a caption, and showing
|
||||
// "Attachment" over the words the sender actually wrote would be a
|
||||
// step back from the raw `content` this replaced.
|
||||
previewEvent.content.isNotBlank() -> previewEvent.content
|
||||
hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media)
|
||||
else -> stringRes(Res.string.marmot_preview_no_text)
|
||||
}
|
||||
// Only a genuinely known name earns the prefix: `bestName` returns null
|
||||
// without metadata, and "a1b2c3d4: hi" is noise, not attribution.
|
||||
//
|
||||
// Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a
|
||||
// plain StateFlow.value read, so a kind:0 arriving after the row composed
|
||||
// would never reach it.
|
||||
//
|
||||
// Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only
|
||||
// subscribes for a sender the cache already knows, and a sender it does not
|
||||
// simply goes unprefixed. Creating a User per unknown sender would put a
|
||||
// metadata REQ behind every row of a list that is mostly strangers' names
|
||||
// the reader never asked for — a preview line is not worth that.
|
||||
val senderUser =
|
||||
previewEvent
|
||||
?.pubKey
|
||||
?.takeIf { it != myPubKey }
|
||||
?.let { LocalCache.getUserIfExists(it) }
|
||||
val senderName =
|
||||
if (senderUser != null) {
|
||||
observeUserInfo(senderUser, accountViewModel).value?.info?.bestName()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val previewText =
|
||||
// A system caption already names its actor, so prefixing one would say it twice.
|
||||
if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) {
|
||||
stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody)
|
||||
} else {
|
||||
previewBody
|
||||
}
|
||||
val previewText = marmotGroupPreviewText(newestMessage, accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
|
||||
+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.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
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
@@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.model.Account
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
|
||||
@@ -59,6 +60,9 @@ open class MarmotNewMessageViewModel : ViewModel() {
|
||||
val message = TextFieldState()
|
||||
val replyTo = mutableStateOf<Note?>(null)
|
||||
|
||||
// My own message being replaced; the next send publishes a kind:1009 edit of it.
|
||||
val editingMessage = mutableStateOf<Note?>(null)
|
||||
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
|
||||
@@ -83,11 +87,28 @@ open class MarmotNewMessageViewModel : ViewModel() {
|
||||
this.chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
this.message.clearText()
|
||||
this.replyTo.value = null
|
||||
this.editingMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun reply(note: Note) {
|
||||
replyTo.value = note
|
||||
// Leaving edit mode drops the edited message's prefilled text too; kept, Send would
|
||||
// post it again as a new reply.
|
||||
if (editingMessage.value != null) cancelEdit()
|
||||
}
|
||||
|
||||
/** Enter edit mode for my own [note], prefilled with the text it currently shows. */
|
||||
fun editMarmotMessage(note: Note) {
|
||||
replyTo.value = null
|
||||
editingMessage.value = note
|
||||
val current = note.latestMarmotEdit()?.event?.content ?: note.event?.content ?: ""
|
||||
message.setTextAndPlaceCursorAtEnd(current)
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
editingMessage.value = null
|
||||
message.clearText()
|
||||
}
|
||||
|
||||
fun clearReply() {
|
||||
@@ -137,6 +158,15 @@ open class MarmotNewMessageViewModel : ViewModel() {
|
||||
val text = message.text.toString().trim()
|
||||
if (text.isEmpty()) return
|
||||
|
||||
val editing = editingMessage.value
|
||||
if (editing != null) {
|
||||
accountViewModel.sendMarmotGroupMessageEdit(groupId, editing, text)
|
||||
editingMessage.value = null
|
||||
onUiThread { message.clearText() }
|
||||
userSuggestions?.reset()
|
||||
return
|
||||
}
|
||||
|
||||
// Capture id+pubKey snapshot before suspending so a slow send
|
||||
// doesn't race a user-cleared reply state.
|
||||
val parentEvent = replyTo.value?.event
|
||||
|
||||
+2
-26
@@ -66,9 +66,7 @@ import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.back
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_editing_banner
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
|
||||
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
|
||||
@@ -108,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.Ch
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.EditingMessageBanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.toConcordImeta
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
@@ -455,30 +454,7 @@ private fun ConcordMessageComposer(
|
||||
// Edit mode: a banner reminding the user the next send replaces this message (a kind-1010
|
||||
// edit on the channel plane), with an X to abandon the edit and clear the field.
|
||||
newMessageModel.editingMessage.value?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SymbolIcon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(Res.string.concord_editing_banner),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f).padding(start = 8.dp),
|
||||
)
|
||||
IconButton(onClick = { newMessageModel.cancelEdit() }) {
|
||||
SymbolIcon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(Res.string.cancel),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
EditingMessageBanner(onCancel = { newMessageModel.cancelEdit() })
|
||||
}
|
||||
|
||||
Column(modifier = EditFieldModifier) {
|
||||
|
||||
+3
-1
@@ -133,7 +133,9 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
fun reply(note: Note) {
|
||||
replyTo.value = note
|
||||
replyMode.value = ReplyMode.INLINE
|
||||
editingMessage.value = null
|
||||
// Leaving edit mode drops the edited message's prefilled text too; kept, Send would
|
||||
// post it again as a new reply.
|
||||
if (editingMessage.value != null) cancelEdit()
|
||||
}
|
||||
|
||||
/** Enter edit mode for my own [note]: prefills the field with its current text; sending publishes a kind-1010 edit. */
|
||||
|
||||
+28
-15
@@ -103,7 +103,6 @@ import com.vitorpamplona.amethyst.commons.resources.geohash_chat
|
||||
import com.vitorpamplona.amethyst.commons.resources.leave
|
||||
import com.vitorpamplona.amethyst.commons.resources.loading_feed
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_no_messages_yet
|
||||
import com.vitorpamplona.amethyst.commons.resources.mute_notifications
|
||||
import com.vitorpamplona.amethyst.commons.resources.muted_chat_content_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.pin_conversation
|
||||
@@ -147,6 +146,9 @@ import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupPreviewText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupTitle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotOtherMembers
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
|
||||
@@ -516,13 +518,34 @@ private fun MarmotGroupRoomCompose(
|
||||
val relays by chatroom.relays.collectAsStateWithLifecycle()
|
||||
val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle()
|
||||
|
||||
val author = lastMessage.author
|
||||
val noteEvent = lastMessage.event
|
||||
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
|
||||
val members by chatroom.members.collectAsStateWithLifecycle()
|
||||
val otherMembers = remember(members) { marmotOtherMembers(members, accountViewModel.account.signer.pubKey) }
|
||||
val groupName = marmotGroupTitle(displayName, otherMembers, chatroom.nostrGroupId, accountViewModel)
|
||||
|
||||
// The row is handed the group's placeholder note when it has no messages.
|
||||
val lastContent = marmotGroupPreviewText(lastMessage.takeIf { it.event != null }, accountViewModel)
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle()
|
||||
|
||||
val hasGroupFace = avatarUrl != null || image != null || !displayName.isNullOrBlank()
|
||||
if (!hasGroupFace && otherMembers.isNotEmpty()) {
|
||||
// An unnamed group without an avatar (White Noise's 1:1 chats) is about its people:
|
||||
// show them, as a NIP-17 room does, instead of a relay icon.
|
||||
ChannelName(
|
||||
channelPicture = { NonClickableUserPictures(userHexList = otherMembers, size = Size55dp, accountViewModel = accountViewModel) },
|
||||
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, MaterialSymbols.Lock, Res.string.marmot_group, modifier) },
|
||||
channelLastTime = lastMessage.createdAt(),
|
||||
channelLastContent = lastContent,
|
||||
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
|
||||
onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the group's own avatar — the plain https link first, then the
|
||||
// encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon
|
||||
// of one of the group's relays (fetched on a cache miss).
|
||||
// of one of the group's relays (fetched on a cache miss). Resolved only here: a row that
|
||||
// shows its members' faces above never draws it, and the relay icon is a NIP-11 fetch.
|
||||
val channelPicture =
|
||||
if (avatarUrl != null || image != null) {
|
||||
rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys)
|
||||
@@ -530,16 +553,6 @@ private fun MarmotGroupRoomCompose(
|
||||
loadMarmotRelayIcon(relays)
|
||||
}
|
||||
|
||||
val lastContent =
|
||||
if (author != null && noteEvent != null) {
|
||||
val authorName by observeUserName(author, accountViewModel)
|
||||
"$authorName: ${noteEvent.content.take(200)}"
|
||||
} else {
|
||||
stringRes(Res.string.marmot_group_no_messages_yet)
|
||||
}
|
||||
|
||||
val lastReadTime by accountViewModel.account.loadLastReadFlow(marmotGroupLastReadRoute(chatroom.nostrGroupId)).collectAsStateWithLifecycle()
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = chatroom.nostrGroupId,
|
||||
channelPicture = channelPicture,
|
||||
|
||||
+4
-1
@@ -78,7 +78,10 @@ class ChatFileUploadState(
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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
@@ -672,7 +672,10 @@ class LongFormPostViewModel :
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
|
||||
+4
-1
@@ -515,7 +515,10 @@ open class NewProductViewModel :
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
|
||||
+4
-1
@@ -1754,7 +1754,10 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
|
||||
+10
-3
@@ -401,11 +401,16 @@ private fun EncryptedKeyCard(
|
||||
var encrypted by remember { mutableStateOf<String?>(null) }
|
||||
var showQr by remember { mutableStateOf(false) }
|
||||
|
||||
// Drop the result while in the background. A password not yet used is kept so switching to
|
||||
// a password manager to fetch it doesn't wipe the fields; a used one is cleared on success.
|
||||
// Drop the result and the typed password while in the background, so neither sits in memory
|
||||
// (or on the recents thumbnail, with the password shown) behind another app. The cost: leaving
|
||||
// to copy a password out of a password manager app clears what was typed; autofill, which
|
||||
// fills in place without stopping this activity, is unaffected.
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
encrypted = null
|
||||
showQr = false
|
||||
password = ""
|
||||
repeated = ""
|
||||
showPassword = false
|
||||
}
|
||||
|
||||
// A typo in the password makes the backup permanently useless, so it must be typed twice.
|
||||
@@ -415,9 +420,11 @@ private fun EncryptedKeyCard(
|
||||
|
||||
fun encrypt() {
|
||||
if (!canEncrypt) return
|
||||
// Read before the gate: a device-credential prompt is its own activity, so it stops this
|
||||
// one and the ON_STOP above clears the fields before the unlock callback runs.
|
||||
val currentPassword = password
|
||||
gate.withAccess {
|
||||
val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess
|
||||
val currentPassword = password
|
||||
working = true
|
||||
// NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread.
|
||||
scope.launch {
|
||||
|
||||
+4
-1
@@ -558,7 +558,10 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
val orchestrator = multiOrchestrator ?: return
|
||||
orchestrator.remove(selected)
|
||||
// An empty orchestrator still renders the gallery, which reads its first item.
|
||||
if (orchestrator.size() == 0) multiOrchestrator = null
|
||||
}
|
||||
|
||||
override fun onMessageChanged() {
|
||||
|
||||
+2
-2
@@ -185,9 +185,9 @@ fun RelayStatusRow(
|
||||
stringRes(Res.string.errors),
|
||||
modifier = Size15Modifier,
|
||||
tint =
|
||||
if (successRate < 0.1) {
|
||||
if (successRate < 10) {
|
||||
MaterialTheme.colorScheme.redColorOnSecondSurface
|
||||
} else if (successRate < 0.60) {
|
||||
} else if (successRate < 60) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
|
||||
+8
-3
@@ -274,9 +274,14 @@ private fun PasswordField(loginViewModel: LoginViewModel) {
|
||||
onGo = loginViewModel::login,
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
passwordFocusRequester.requestFocus()
|
||||
// Only once the key is complete: a pasted ncryptsec jumps straight to the password,
|
||||
// one being typed keeps its focus until the last character.
|
||||
val keyComplete = loginViewModel.isCompleteNcryptsec
|
||||
LaunchedEffect(keyComplete) {
|
||||
if (keyComplete) {
|
||||
delay(300)
|
||||
passwordFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
|
||||
@Stable
|
||||
class LoginViewModel : ViewModel() {
|
||||
@@ -62,6 +63,20 @@ class LoginViewModel : ViewModel() {
|
||||
Bech32Transcription.normalize(key.text).startsWith("ncryptsec1")
|
||||
}
|
||||
|
||||
/**
|
||||
* The key field holds a whole, checksummed ncryptsec. [needsPassword] turns true at the
|
||||
* first "ncryptsec1", which is what shows the password field; moving focus there has to
|
||||
* wait for this, or a key typed by hand loses focus after its tenth character.
|
||||
*/
|
||||
val isCompleteNcryptsec by derivedStateOf {
|
||||
needsPassword &&
|
||||
try {
|
||||
Bech32Transcription.normalize(key.text).bechToBytes("ncryptsec").size == NCRYPTSEC_PAYLOAD_SIZE
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var isFirstLogin by mutableStateOf(false)
|
||||
|
||||
fun init(accountSessionManager: AccountSessionManager) {
|
||||
@@ -192,4 +207,9 @@ class LoginViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// version + log_n + salt(16) + nonce(24) + key security + ciphertext(48), per NIP-49.
|
||||
private const val NCRYPTSEC_PAYLOAD_SIZE = 91
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,14 @@ object MessageCommands {
|
||||
// A deletion is not its own row either. The retracted body is
|
||||
// blanked rather than the row dropped, so a harness (or a reader
|
||||
// paging back) can tell "retracted" from "never arrived".
|
||||
val deleted = ctx.marmot.deletedIds(parsed)
|
||||
val deleted =
|
||||
ctx.marmot.deletedIds(
|
||||
parsed,
|
||||
ctx.marmot
|
||||
.groupView(gid)
|
||||
?.adminPubkeys
|
||||
?.toSet() ?: emptySet(),
|
||||
)
|
||||
// Pinned at persist time from the retention of the epoch that
|
||||
// DELIVERED each message, so it is the message's own expiry and not
|
||||
// a recomputation against whatever the group's setting is now.
|
||||
|
||||
+23
-1
@@ -170,6 +170,12 @@ the Marmot protocol, via its `wn` / `wnd` binaries (the `wn-cli` package).
|
||||
Every test records a pass/fail/skip result into a tab-separated log, and the
|
||||
summary is printed at the end of the run.
|
||||
|
||||
The MDK checkout is pinned (`MDK_PIN` in `marmot/setup.sh`) to the commit the
|
||||
shipping White Noise apps embed, read from their `MARMOT_VERSION` lockfiles;
|
||||
when Android and iOS disagree the newer one wins. As of 2026-09-27 that is
|
||||
`03b1809e` (White Noise Android; iOS is on the 0.10.4 release `fcc85edd`, an
|
||||
ancestor). Override with `MDK_PIN=<sha>` to test another MDK.
|
||||
|
||||
> These harnesses previously targeted `marmot-protocol/whitenoise-rs`, which was
|
||||
> archived on 2026-08-05 pinned to `mdk-core 0.8.0`. Testing against it meant
|
||||
> testing against a frozen MIP-era client. The reference moved into `mdk`, and
|
||||
@@ -214,6 +220,22 @@ would make strict-mode DM sends spuriously fail. `127.0.0.2` is still
|
||||
pure loopback and isn't matched by that filter. Override with
|
||||
`--host 127.0.0.5` etc. if `127.0.0.2` is taken.
|
||||
|
||||
**macOS:** Linux answers every `127.0.0.0/8` address on `lo`; macOS only
|
||||
answers `127.0.0.1` until you add an alias, once per boot:
|
||||
|
||||
```bash
|
||||
sudo ifconfig lo0 alias 127.0.0.2 up
|
||||
```
|
||||
|
||||
The harnesses check for it and stop with that command instead of failing
|
||||
later with "Can't assign requested address". Two more macOS traps are handled
|
||||
for you: `wnd` sockets are moved to a short `/tmp/wnd.*` directory when the
|
||||
checkout path is too long for `sun_path` ("path must be shorter than
|
||||
SUN_LEN"), and `amy` is rebuilt incrementally on every run (a stale
|
||||
`cli/build/install/amy` from another branch produces misleading publish
|
||||
failures), unless you pass `--no-build`. A run killed mid-way can leave
|
||||
`amy serve` holding the relay port; `pkill -f "amy.*serve"` frees it.
|
||||
|
||||
**Note:** dm-05 validates the kind:15 wire format via reference mode
|
||||
(caller supplies the URL + AES-GCM key/nonce). The upload-mode variant
|
||||
(`dm send-file --file PATH --server URL`) needs a local Blossom server
|
||||
@@ -240,7 +262,7 @@ On the Android side:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd tools/marmot-interop
|
||||
cd cli/tests/marmot
|
||||
./marmot-interop.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -66,6 +66,35 @@ assert_eq() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# macOS caps a unix socket path at 104 bytes (sun_path) and wnd refuses a longer
|
||||
# one ("path must be shorter than SUN_LEN"). wnd binds first inside a staging
|
||||
# dir next to the final path (`.sock.<pid>.<name>/<name>`, ~30 bytes more), so a
|
||||
# checkout under an ordinary home dir already crosses it: ~85 bytes failed. Move
|
||||
# such a socket into a short private temp dir (0700, since wnd also refuses a
|
||||
# socket dir others can read).
|
||||
#
|
||||
# The dir is derived from the long path, not random: the same checkout gets the same
|
||||
# socket every run, so `start_daemon` still finds a wnd a killed run left behind
|
||||
# instead of starting a second one on the same data, and runs stop piling up dirs.
|
||||
short_socket_path() {
|
||||
local path="$1"
|
||||
if [[ ${#path} -lt 70 ]]; then printf '%s' "$path"; return; fi
|
||||
# Resolved, not /tmp itself: on macOS /tmp is a symlink to /private/tmp and wnd
|
||||
# refuses a socket path through an alias ("untrusted directory alias").
|
||||
local tmp dir
|
||||
tmp="$(cd /tmp && pwd -P)"
|
||||
dir="$tmp/wnd-$(id -u)-$(printf '%s' "$path" | cksum | cut -d' ' -f1)"
|
||||
if ! mkdir -m 700 "$dir" 2>/dev/null; then
|
||||
# Reuse only a dir that is ours and private: /tmp is shared, and a socket in a
|
||||
# dir someone else controls could be swapped under wnd.
|
||||
if [[ ! -d "$dir" || -L "$dir" || ! -O "$dir" ]]; then
|
||||
dir="$(mktemp -d "$tmp/wnd.XXXXXX")"
|
||||
fi
|
||||
chmod 700 "$dir"
|
||||
fi
|
||||
printf '%s/%s.sock' "$dir" "$(basename "$(dirname "$path")")"
|
||||
}
|
||||
|
||||
# --- embedded relay (amy serve → geode) --------------------------------------
|
||||
# Every relay-backed harness talks to ONE loopback relay, and that relay is
|
||||
# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy
|
||||
@@ -97,6 +126,15 @@ start_local_relay() {
|
||||
local bind="${RELAY_BIND:-$RELAY_HOST}"
|
||||
mkdir -p "$relay_home" "$RELAY_DATA/logs"
|
||||
|
||||
# Linux answers all of 127.0.0.0/8 on lo; macOS only 127.0.0.1 until an alias
|
||||
# is added, and binding to anything else fails with a bare "Can't assign
|
||||
# requested address" deep in the relay log.
|
||||
if [[ "$(uname -s)" == "Darwin" && "$bind" == 127.* && "$bind" != "127.0.0.1" ]] \
|
||||
&& ! ifconfig lo0 2>/dev/null | grep -q "inet $bind "; then
|
||||
fail_msg "$bind is not on lo0. On macOS add it once per boot: sudo ifconfig lo0 alias $bind up"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; }
|
||||
|
||||
# Abort early if something else is already bound to the port — failing
|
||||
|
||||
@@ -199,6 +199,29 @@ jq_list() {
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Whether <b|c>'s wn lists <hex> in the group's <members|admins>. Three answers, because a
|
||||
# query that fails is not evidence of absence: piped straight into `jq -e select`, a dead
|
||||
# daemon or an `ok:false` reply read exactly like "not listed" and passed removal tests.
|
||||
# 0 listed · 1 wn answered and <hex> is not listed · 2 no usable answer
|
||||
wn_lists() {
|
||||
local who="$1" gid="$2" list="$3" hex="$4" out
|
||||
out=$("wn_$who" --json groups members "$gid" 2>/dev/null) || return 2
|
||||
printf '%s' "$out" | jq -e '.ok == true' >/dev/null 2>&1 || return 2
|
||||
if printf '%s' "$out" | jq_list "$list" | jq -e --arg p "$hex" \
|
||||
'select((.member_id // .admin_id // .pubkey // .public_key) == $p)' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Whether <b|c>'s wn reports that it was itself removed from the group: MDK's
|
||||
# `groups show` carries `self_membership: "removed"` once the removal commit applied.
|
||||
wn_self_removed() {
|
||||
local who="$1" gid="$2"
|
||||
"wn_$who" --json groups show "$gid" 2>/dev/null \
|
||||
| jq -e '.ok == true and .result.group.self_membership == "removed"' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# npub or hex pubkey of one member/admin entry. MDK names the field per
|
||||
# collection: members carry `member_id`, admins carry `admin_id`.
|
||||
jq_member_ids() {
|
||||
|
||||
@@ -140,6 +140,8 @@ source "$TESTS_DIR/lib.sh"
|
||||
source "$SCRIPT_DIR/setup.sh"
|
||||
# shellcheck source=../headless/helpers.sh
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
B_SOCKET="$(short_socket_path "$B_SOCKET")"
|
||||
C_SOCKET="$(short_socket_path "$C_SOCKET")"
|
||||
# shellcheck source=tests-create.sh
|
||||
source "$SCRIPT_DIR/tests-create.sh"
|
||||
# shellcheck source=tests-manage.sh
|
||||
@@ -212,6 +214,9 @@ ALL_TESTS=(
|
||||
test_29_disband_amy_to_wn
|
||||
test_30_wn_commit_after_app_data_update
|
||||
test_31_reaction_materializes_on_wn
|
||||
test_32_amy_message_after_wn_commit
|
||||
test_33_wn_leaves_amy_admin_group
|
||||
test_34_amy_removes_last_other_member
|
||||
)
|
||||
|
||||
# --tests runs a subset in the order given. Most tests read state a previous
|
||||
|
||||
@@ -111,6 +111,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
|
||||
source "$TESTS_DIR/headless/helpers.sh"
|
||||
B_SOCKET="$(short_socket_path "$B_SOCKET")"
|
||||
C_SOCKET="$(short_socket_path "$C_SOCKET")"
|
||||
|
||||
# --- preflight ---------------------------------------------------------------
|
||||
preflight() {
|
||||
|
||||
+25
-15
@@ -17,26 +17,35 @@ preflight() {
|
||||
info "$cmd: $(command -v "$cmd")"
|
||||
done
|
||||
|
||||
# Build `amy` via gradle if missing.
|
||||
# Build `amy` via gradle.
|
||||
#
|
||||
# jitpack.io and dl.google.com both return transient 503s on a non-trivial
|
||||
# fraction of cold-cache fetches, and Gradle disables the entire repository
|
||||
# for the rest of the build the moment a single 503 lands — so one bad
|
||||
# roll aborts the whole harness. Retry a few times; each attempt resumes
|
||||
# from Gradle's cache so only the still-missing artifacts get re-fetched.
|
||||
if [[ ! -x "$AMY_BIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
|
||||
fi
|
||||
local attempt max_attempts=4
|
||||
#
|
||||
# Build even when the binary exists: an old install/amy from another branch
|
||||
# tests yesterday's code and fails in misleading ways (a stale amy produced
|
||||
# "UNIQUE constraint" publish rejections that looked like relay bugs). Gradle
|
||||
# makes an up-to-date install a no-op. --no-build keeps whatever is there.
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1; }
|
||||
warn "--no-build: using the existing $AMY_BIN, which may be older than this checkout"
|
||||
else
|
||||
local attempt max_attempts=4 built=0
|
||||
for attempt in $(seq 1 $max_attempts); do
|
||||
step "building :cli:installDist (attempt $attempt/$max_attempts)"
|
||||
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
|
||||
&& [[ -x "$AMY_BIN" ]]; then
|
||||
break
|
||||
( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE"
|
||||
# gradle's status, not tee's: a failed build must not fall through to the old binary.
|
||||
if [[ "${PIPESTATUS[0]}" -eq 0 && -x "$AMY_BIN" ]]; then
|
||||
built=1; break
|
||||
fi
|
||||
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
|
||||
done
|
||||
# The binary may still exist from an earlier build; running the suite on it would test
|
||||
# code this checkout no longer has.
|
||||
[[ "$built" -eq 1 ]] || { fail_msg "amy build failed after $max_attempts attempts"; exit 1; }
|
||||
fi
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
|
||||
info "amy: $AMY_BIN"
|
||||
@@ -68,17 +77,18 @@ preflight() {
|
||||
# what users are running", which is the question the harness exists to answer.
|
||||
#
|
||||
# THE TWO APPS NO LONGER AGREE, and the rule for that is: take the newer.
|
||||
# As of 2026-09-10 android is on 0.9.21 (`fdd398a8`) and ios is still on
|
||||
# 0.9.20 (`2f44f6b6`) — android syncs its bindings on its own cadence and got
|
||||
# As of 2026-09-27 android is on a master snapshot after 0.10.4
|
||||
# (`03b1809e`, 2026-09-26) and ios on the 0.10.4 release (`fcc85edd`), which
|
||||
# is an ancestor of it — android syncs its bindings on its own cadence and got
|
||||
# there first. The newer one is where new validation lands, so it is where
|
||||
# drift shows up first; a client that satisfies 0.9.21 satisfies 0.9.20,
|
||||
# since every 0.9.20 rule is still in 0.9.21. Pinning to the laggard would
|
||||
# test the subset and call it coverage.
|
||||
# drift shows up first; a client that satisfies the newer MDK satisfies the
|
||||
# older one. Pinning to the laggard would test the subset and call it
|
||||
# coverage.
|
||||
#
|
||||
# Bump it deliberately, by reading those lockfiles again — not by drifting.
|
||||
# If they agree again, that is the value; if they disagree, take the newer
|
||||
# and say so here.
|
||||
MDK_PIN="${MDK_PIN:-fdd398a80f1626f1713787cebe416f7890b5b204}"
|
||||
MDK_PIN="${MDK_PIN:-03b1809e6387f2d95e566a6a2d2f1212bec4d41b}"
|
||||
if [[ "$(git -C "$WN_REPO" rev-parse HEAD 2>/dev/null)" != "$MDK_PIN" ]]; then
|
||||
if [[ "$NO_BUILD" -eq 1 ]]; then
|
||||
info "mdk is not at the pinned $MDK_PIN and --no-build set — testing whatever is checked out"
|
||||
|
||||
@@ -27,10 +27,7 @@ test_06_member_removal() {
|
||||
# C should no longer see the group on its own member view.
|
||||
local deadline=$(( $(date +%s) + 120 )) removed=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ! wn_c --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq_list members | jq -e --arg p "$C_HEX" \
|
||||
'select((.member_id // .pubkey // .public_key) == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
if wn_self_removed c "$mls_gid"; then
|
||||
removed=1; break
|
||||
fi
|
||||
sleep 3
|
||||
@@ -180,10 +177,9 @@ test_11_leave_group() {
|
||||
|
||||
local deadline=$(( $(date +%s) + 120 )) gone=0
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq_list admins | jq -e --arg p "$A_HEX" \
|
||||
'select((.admin_id // .pubkey // .public_key) == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
local rc=0
|
||||
wn_lists b "$mls_gid" admins "$A_HEX" || rc=$?
|
||||
if [[ "$rc" -eq 1 ]]; then
|
||||
gone=1; break
|
||||
fi
|
||||
sleep 3
|
||||
@@ -222,10 +218,9 @@ test_17_group_image_commit() {
|
||||
|
||||
# Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed
|
||||
# A) — this test only makes sense while A can still commit to the group.
|
||||
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
|
||||
| jq_list members | jq -e --arg p "$A_HEX" \
|
||||
'select((.member_id // .pubkey // .public_key) == $p)' \
|
||||
>/dev/null 2>&1; then
|
||||
local listed=0
|
||||
wn_lists b "$mls_gid" members "$A_HEX" || listed=$?
|
||||
if [[ "$listed" -eq 1 ]]; then
|
||||
record_result "$id" skip "A not in GROUP_02"; return
|
||||
fi
|
||||
|
||||
@@ -337,3 +332,104 @@ test_31_reaction_materializes_on_wn() {
|
||||
printf '%s\n' "$tl" >> "$LOG_FILE"
|
||||
record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)"
|
||||
}
|
||||
|
||||
# The other direction of test 30. After wn commits (a rename carries an
|
||||
# UpdatePath), both sides reached the same epoch and wn's messages kept
|
||||
# decrypting on amy, yet wn never showed another amy message.
|
||||
test_32_amy_message_after_wn_commit() {
|
||||
banner "Test 32 — amy's message after wn's commit reaches wn"
|
||||
local id="32 amy after wn commit"
|
||||
|
||||
local out gid mls_gid b_gid
|
||||
out=$(amy_json marmot group create --name "Interop-32") || { record_result "$id" fail "amy group create failed"; return; }
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id')
|
||||
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
|
||||
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
|
||||
b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; }
|
||||
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
|
||||
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
|
||||
|
||||
amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy promote failed"; return; }
|
||||
sleep 5
|
||||
wn_b groups rename "$mls_gid" "Interop-32-by-wn" >/dev/null 2>&1 || true
|
||||
amy_json marmot await rename "$gid" --name "Interop-32-by-wn" --timeout 120 >/dev/null || { record_result "$id" fail "amy did not apply wn's rename"; return; }
|
||||
|
||||
amy_json marmot message send "$gid" "32 from amy after wn commit" >/dev/null || { record_result "$id" fail "amy send failed"; return; }
|
||||
if wait_for_message B "$mls_gid" "32 from amy after wn commit" 90; then
|
||||
record_result "$id" pass
|
||||
else
|
||||
record_result "$id" fail "wn never received amy's message sent after wn's commit"
|
||||
fi
|
||||
}
|
||||
|
||||
test_33_wn_leaves_amy_admin_group() {
|
||||
banner "Test 33 — wn leaves a group amy administers; amy commits the departure"
|
||||
local id="33 wn leaves amy's group"
|
||||
|
||||
# Leaving is a SelfRemove proposal that only an admin can commit. Test 15
|
||||
# covers a wn admin committing it; here amy is the only admin, so the
|
||||
# departure takes effect only if amy commits it during sync.
|
||||
local out gid mls_gid b_gid
|
||||
out=$(amy_json marmot group create --name "Interop-33") || { record_result "$id" fail "amy group create failed"; return; }
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id')
|
||||
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
|
||||
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
|
||||
b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; }
|
||||
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
|
||||
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
|
||||
|
||||
wn_b groups leave "$mls_gid" >/dev/null 2>&1 || { record_result "$id" fail "wn leave failed"; return; }
|
||||
|
||||
local deadline=$(( $(date +%s) + 120 )) show b_still=1
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
show=$(amy_json marmot group show "$gid" 2>/dev/null) || { sleep 3; continue; }
|
||||
b_still=$(printf '%s' "$show" | jq --arg p "$B_HEX" '[.members[]? | select((.pubkey // .member_id) == $p)] | length')
|
||||
[[ "$b_still" == "0" ]] && break
|
||||
sleep 3
|
||||
done
|
||||
if [[ "$b_still" == "0" ]]; then
|
||||
record_result "$id" pass
|
||||
else
|
||||
record_result "$id" fail "amy never committed wn's SelfRemove; wn is still in the tree"
|
||||
fi
|
||||
}
|
||||
|
||||
test_34_amy_removes_last_other_member() {
|
||||
banner "Test 34 — amy removes the only other member; wn processes its own removal"
|
||||
local id="34 amy removes wn from a 2-member group"
|
||||
|
||||
# Test 06 removes one of three members. Removing the only other member leaves
|
||||
# the committer alone in the tree, which is the shape a device removal hit:
|
||||
# White Noise never applied it and kept showing itself as a member.
|
||||
local out gid mls_gid b_gid
|
||||
out=$(amy_json marmot group create --name "Interop-34") || { record_result "$id" fail "amy group create failed"; return; }
|
||||
gid=$(printf '%s' "$out" | jq -r '.group_id')
|
||||
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
|
||||
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
|
||||
b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; }
|
||||
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
|
||||
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
|
||||
|
||||
wn_b messages send "$mls_gid" "34 before removal" >/dev/null 2>&1 || true
|
||||
amy_json marmot await message "$gid" --match "34 before removal" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got wn's message"; return; }
|
||||
|
||||
amy_json marmot group remove "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy remove failed"; return; }
|
||||
|
||||
local deadline=$(( $(date +%s) + 120 )) gone=0 view
|
||||
while [[ $(date +%s) -lt $deadline ]]; do
|
||||
wn_b sync >/dev/null 2>&1 || true
|
||||
# A positive signal: wn marks its own copy removed once it applied the commit. Reading
|
||||
# "B is not in the member list" instead passed whenever the query itself failed.
|
||||
view=$(wn_b_json groups show "$mls_gid" 2>/dev/null || true)
|
||||
if wn_self_removed b "$mls_gid"; then
|
||||
gone=1; break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
printf '%s' "$view" >"$STATE_DIR/test34-wn-view.json"
|
||||
if [[ "$gone" -eq 1 ]]; then
|
||||
record_result "$id" pass
|
||||
else
|
||||
record_result "$id" fail "wn still lists itself as a member after amy removed it"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -439,8 +439,15 @@ test_27_deletion_wn_to_amy() {
|
||||
record_result "$id" fail "amy has no event id for wn's message"; return
|
||||
fi
|
||||
|
||||
if ! wn_b messages delete "$mls_gid" "$target" >/dev/null 2>&1; then
|
||||
# Keep wn's answer: "the command ran" and "the tombstone reached a relay" are
|
||||
# different, and only the second one can reach amy.
|
||||
local del
|
||||
del=$(wn_b --json messages delete "$mls_gid" "$target" 2>>"$LOG_FILE") || {
|
||||
record_result "$id" fail "wn messages delete failed"; return
|
||||
}
|
||||
printf 'wn messages delete %s -> %s\n' "$target" "$del" >>"$LOG_FILE"
|
||||
if ! printf '%s' "$del" | jq -e '(.result.published // 0) > 0' >/dev/null 2>&1; then
|
||||
record_result "$id" fail "wn did not publish the delete tombstone: $del"; return
|
||||
fi
|
||||
|
||||
# The row stays, blanked and flagged: "retracted" and "never arrived" are
|
||||
@@ -460,7 +467,12 @@ test_27_deletion_wn_to_amy() {
|
||||
done
|
||||
|
||||
if [[ "$gone" -ne 1 ]]; then
|
||||
record_result "$id" fail "amy never marked wn's message deleted"; return
|
||||
# Tell a lost tombstone from a split group: if the two sides sit on different
|
||||
# epochs, the delete was sent where amy cannot follow.
|
||||
local wn_epoch amy_epoch
|
||||
wn_epoch=$(wn_b_json groups show "$mls_gid" 2>/dev/null | jq -r '.result.mls.epoch // "?"')
|
||||
amy_epoch=$(amy_json marmot group show "$gid" 2>/dev/null | jq -r '.epoch // "?"')
|
||||
record_result "$id" fail "amy never marked wn's message deleted (wn epoch $wn_epoch, amy epoch $amy_epoch)"; return
|
||||
fi
|
||||
if [[ -n "$body" ]]; then
|
||||
record_result "$id" fail "amy flagged the message deleted but still shows '$body'"; return
|
||||
@@ -695,6 +707,7 @@ test_29_disband_amy_to_wn() {
|
||||
wn_b sync >/dev/null 2>&1 || true
|
||||
sleep 5
|
||||
done
|
||||
wn_b_json groups show "$mls_gid" >"$STATE_DIR/disband29-wn-view.json" 2>&1 || true
|
||||
printf 'disband29 epoch %s -> %s (amy now %s), wn at %s\n' \
|
||||
"$before_epoch" "$after_epoch" "${amy_now:-?}" "${saw:-<none>}" >>"$LOG_FILE"
|
||||
|
||||
|
||||
+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.MarmotGroupImage
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.marmot.GroupEventResult
|
||||
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
|
||||
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore
|
||||
@@ -82,6 +84,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
|
||||
@@ -177,6 +180,8 @@ class MarmotManager(
|
||||
* Restore all Marmot state from persistent storage.
|
||||
* Call once during Account initialization.
|
||||
*/
|
||||
private val desync = MarmotDesyncDetector()
|
||||
|
||||
suspend fun restoreAll() {
|
||||
Log.d("MarmotManager") { "restoreAll(): begin for ${signer.pubKey.take(8)}…" }
|
||||
try {
|
||||
@@ -186,8 +191,13 @@ class MarmotManager(
|
||||
// syncWithGroupManager fills in default (since = null) entries,
|
||||
// so even the first filter set sent to relays skips the
|
||||
// already-processed kind:445 backlog.
|
||||
subscriptionSinceFromStoredMessages(activeIds).forEach { (groupId, since) ->
|
||||
subscriptionManager.subscribeGroup(groupId, since)
|
||||
newestStoredMessageTimes(activeIds).forEach { (groupId, newest) ->
|
||||
// The newest event this group decrypted before the restart is where "behind"
|
||||
// starts counting (see MarmotDesyncDetector).
|
||||
desync.seed(groupId, newest)
|
||||
if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) {
|
||||
subscriptionManager.subscribeGroup(groupId, newest - GROUP_EVENT_REFETCH_OVERLAP_SEC)
|
||||
}
|
||||
}
|
||||
subscriptionManager.syncWithGroupManager(activeIds)
|
||||
// Seed convergence with each restored state. A commit that arrives
|
||||
@@ -204,7 +214,8 @@ class MarmotManager(
|
||||
keyPackageRotationManager.restoreFromStore()
|
||||
ingestDedupStore?.loadAll()?.let { marks ->
|
||||
terminallyIngestedMutex.withLock { terminallyIngested.addAll(marks) }
|
||||
Unit
|
||||
val created = activeIds.filter { createdLocallyMarker(it) in marks }
|
||||
createdLocallyLock.withLock { createdLocally.addAll(created) }
|
||||
}
|
||||
retryPendingPublishObligations()
|
||||
Log.d("MarmotManager") { "restoreAll(): done, ${activeIds.size} groups: $activeIds" }
|
||||
@@ -221,6 +232,23 @@ class MarmotManager(
|
||||
private val terminallyIngested = mutableSetOf<HexKey>()
|
||||
private val terminallyIngestedMutex = Mutex()
|
||||
|
||||
// Groups this device created. Holding leaf 0 is not proof: a commit that removes leaf 0
|
||||
// and adds someone in the same epoch places the invitee there, which let a crafted
|
||||
// invite skip New Requests. So creation leaves a durable marker, stored with the ingest
|
||||
// markers (hashed, so it cannot collide with an event id) and read back on restore.
|
||||
private val createdLocally = mutableSetOf<HexKey>()
|
||||
private val createdLocallyLock = KmpLock()
|
||||
|
||||
private fun createdLocallyMarker(nostrGroupId: HexKey): HexKey = sha256("amethyst:marmot:created-locally:$nostrGroupId".encodeToByteArray()).toHexKey()
|
||||
|
||||
private suspend fun markCreatedLocally(nostrGroupId: HexKey) {
|
||||
createdLocallyLock.withLock { createdLocally.add(nostrGroupId) }
|
||||
markTerminallyIngested(createdLocallyMarker(nostrGroupId))
|
||||
}
|
||||
|
||||
/** Whether this device created [nostrGroupId]. */
|
||||
fun isCreatedLocally(nostrGroupId: HexKey): Boolean = createdLocallyLock.withLock { nostrGroupId in createdLocally }
|
||||
|
||||
suspend fun isTerminallyIngested(eventId: HexKey): Boolean = terminallyIngestedMutex.withLock { eventId in terminallyIngested }
|
||||
|
||||
suspend fun markTerminallyIngested(eventId: HexKey) {
|
||||
@@ -391,7 +419,7 @@ class MarmotManager(
|
||||
* are still fetched; replays inside the window are deduplicated by the
|
||||
* message store and by note identity in the chatroom.
|
||||
*/
|
||||
private suspend fun subscriptionSinceFromStoredMessages(groupIds: Set<HexKey>): Map<HexKey, Long> {
|
||||
private suspend fun newestStoredMessageTimes(groupIds: Set<HexKey>): Map<HexKey, Long> {
|
||||
if (messageStore == null) return emptyMap()
|
||||
val result = mutableMapOf<HexKey, Long>()
|
||||
for (groupId in groupIds) {
|
||||
@@ -408,10 +436,7 @@ class MarmotManager(
|
||||
// and a single future-dated message must not push `since` past
|
||||
// the present — that would skip genuinely new events on every
|
||||
// restart until a fresher message arrives.
|
||||
val newest = minOf(newestStored, TimeUtils.now())
|
||||
if (newest > GROUP_EVENT_REFETCH_OVERLAP_SEC) {
|
||||
result[groupId] = newest - GROUP_EVENT_REFETCH_OVERLAP_SEC
|
||||
}
|
||||
result[groupId] = minOf(newestStored, TimeUtils.now())
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -442,14 +467,17 @@ class MarmotManager(
|
||||
when (result) {
|
||||
is GroupEventResult.ApplicationMessage -> {
|
||||
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
|
||||
desync.onDecrypted(result.groupId, groupEvent.createdAt)
|
||||
}
|
||||
|
||||
is GroupEventResult.CommitProcessed -> {
|
||||
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
|
||||
desync.onDecrypted(result.groupId, groupEvent.createdAt)
|
||||
}
|
||||
|
||||
is GroupEventResult.ProposalStaged -> {
|
||||
subscriptionManager.updateGroupSince(result.groupId, groupEvent.createdAt)
|
||||
desync.onDecrypted(result.groupId, groupEvent.createdAt)
|
||||
}
|
||||
|
||||
is GroupEventResult.CommitPending,
|
||||
@@ -457,13 +485,43 @@ class MarmotManager(
|
||||
is GroupEventResult.UndecryptableOuterLayer,
|
||||
is GroupEventResult.AppMessageOnCandidateBranch,
|
||||
is GroupEventResult.RefusedByLifecycle,
|
||||
is GroupEventResult.Error,
|
||||
-> {}
|
||||
|
||||
is GroupEventResult.Error -> {
|
||||
val groupId = result.groupId
|
||||
if (groupId != null && result.message.startsWith(NO_CANONICAL_EPOCH_ERROR)) {
|
||||
if (desync.onUndecryptable(groupId, groupEvent.id, groupEvent.createdAt)) {
|
||||
Log.w("MarmotManager") { "group ${groupId.take(8)}… is out of sync: newer peer messages decrypt on no epoch here" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Whether the other members of [nostrGroupId] have moved on to epochs this device can't follow. */
|
||||
fun isOutOfSync(nostrGroupId: HexKey): Boolean = desync.isDesynced(nostrGroupId)
|
||||
|
||||
/**
|
||||
* Drop this device's copy of a group it has fallen out of sync with, so it can be
|
||||
* invited back.
|
||||
*
|
||||
* A fork cannot be repaired from this side: the peers hold no copy of our epoch and
|
||||
* we cannot apply theirs, and an external join needs a GroupInfo nobody publishes.
|
||||
* What does work is the ordinary invite path. With the MLS state gone, a new Welcome
|
||||
* is not "already a member" and joins; an admin removes this member and adds it back.
|
||||
* Nothing is published: a SelfRemove at our stale epoch would decrypt for no one.
|
||||
* The decrypted history stays on disk, so it is back when the group is.
|
||||
*/
|
||||
suspend fun resetOutOfSyncGroup(nostrGroupId: HexKey) {
|
||||
subscriptionManager.unsubscribeGroup(nostrGroupId)
|
||||
publishGate.forget(nostrGroupId)
|
||||
groupManager.removeGroupState(nostrGroupId)
|
||||
desync.forget(nostrGroupId)
|
||||
Log.w("MarmotManager") { "reset out-of-sync group ${nostrGroupId.take(8)}…; waiting for a new Welcome" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a WelcomeEvent (kind:444) after NIP-59 unwrapping.
|
||||
* Returns the result including whether KeyPackage rotation is needed.
|
||||
@@ -478,6 +536,8 @@ class MarmotManager(
|
||||
val result = inboundProcessor.processWelcome(welcomeEvent, hintNostrGroupId)
|
||||
|
||||
if (result is WelcomeResult.Joined) {
|
||||
// Joined now: only what is sent from here on has to decrypt.
|
||||
desync.seed(result.nostrGroupId, TimeUtils.now())
|
||||
// An authenticated re-join is what clears a departure gate — the
|
||||
// rule `LocalOutboundGate.REMOVED` states, and `LEAVING` needs it
|
||||
// just as much: a member who left and was invited back holds a gate
|
||||
@@ -743,6 +803,21 @@ class MarmotManager(
|
||||
/** The group's current MLS epoch, which the stream key context binds. */
|
||||
fun currentEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
|
||||
|
||||
/**
|
||||
* The kind:1009 edit rumor alone, for a caller that sends it through its own
|
||||
* show-then-publish path (the app's composer) rather than [buildMessageEdit].
|
||||
*/
|
||||
fun buildMessageEditRumor(
|
||||
targetEventId: HexKey,
|
||||
replacement: String,
|
||||
): Event {
|
||||
val template =
|
||||
eventTemplate<Event>(kind = MarmotAppEvent.KIND_EDIT, description = replacement) {
|
||||
addUnique(arrayOf("e", targetEventId))
|
||||
}
|
||||
return RumorAssembler.assembleRumor(signer.pubKey, template)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a kind:1009 edit that replaces the text of a prior message.
|
||||
*
|
||||
@@ -762,14 +837,7 @@ class MarmotManager(
|
||||
replacement: String,
|
||||
persistOwn: Boolean = true,
|
||||
): TextMessageBundle {
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.eventTemplate<Event>(kind = MarmotAppEvent.KIND_EDIT, description = replacement) {
|
||||
addUnique(arrayOf("e", targetEventId))
|
||||
}
|
||||
val innerEvent =
|
||||
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
.assembleRumor<Event>(signer.pubKey, template)
|
||||
val innerEvent = buildMessageEditRumor(targetEventId, replacement)
|
||||
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
|
||||
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
|
||||
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
|
||||
@@ -1063,7 +1131,9 @@ class MarmotManager(
|
||||
publishGate.satisfyEmptyObligation(nostrGroupId)
|
||||
recordRetentionForCurrentEpoch(nostrGroupId)
|
||||
inboundProcessor.trackGroup(nostrGroupId)
|
||||
desync.seed(nostrGroupId, TimeUtils.now())
|
||||
subscriptionManager.subscribeGroup(nostrGroupId)
|
||||
markCreatedLocally(nostrGroupId)
|
||||
Log.d("MarmotManager") { "createGroup($nostrGroupId): persisted and subscribed" }
|
||||
return nostrGroupId
|
||||
}
|
||||
@@ -1105,7 +1175,9 @@ class MarmotManager(
|
||||
publishGate.satisfyEmptyObligation(nostrGroupId)
|
||||
recordRetentionForCurrentEpoch(nostrGroupId)
|
||||
inboundProcessor.trackGroup(nostrGroupId)
|
||||
desync.seed(nostrGroupId, TimeUtils.now())
|
||||
subscriptionManager.subscribeGroup(nostrGroupId)
|
||||
markCreatedLocally(nostrGroupId)
|
||||
return nostrGroupId
|
||||
}
|
||||
|
||||
@@ -1119,6 +1191,11 @@ class MarmotManager(
|
||||
val confirmed: Boolean,
|
||||
)
|
||||
|
||||
private val commitPreparationLocks = mutableMapOf<HexKey, Mutex>()
|
||||
private val commitPreparationLocksLock = Mutex()
|
||||
|
||||
private suspend fun commitPreparationLock(nostrGroupId: HexKey): Mutex = commitPreparationLocksLock.withLock { commitPreparationLocks.getOrPut(nostrGroupId) { Mutex() } }
|
||||
|
||||
/**
|
||||
* Prepare a local commit, publish it, and apply it only if publication was
|
||||
* acknowledged (`protocol-core/publish-lifecycle.md`).
|
||||
@@ -1140,41 +1217,49 @@ class MarmotManager(
|
||||
ignoringGate: LocalOutboundGate? = null,
|
||||
stage: suspend () -> MlsGroupManager.StagedCommit,
|
||||
): CommitPublication {
|
||||
requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate)
|
||||
// A commit whose publish went unconfirmed leaves the group in
|
||||
// `PendingPublish`, which correctly refuses new commits — but the only
|
||||
// thing that ever resolved it was `restoreAll`, so one dropped socket
|
||||
// wedged the group until the app was restarted. Retrying this group's
|
||||
// obligations here makes the next attempt the recovery: republishing
|
||||
// the same event is safe (a peer deduplicates it by id) and a retry
|
||||
// that still fails leaves the group held exactly as before.
|
||||
if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) {
|
||||
retryPendingPublishObligations(onlyGroupId = nostrGroupId)
|
||||
}
|
||||
check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) {
|
||||
"Group $nostrGroupId cannot prepare a local commit " +
|
||||
"(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})"
|
||||
}
|
||||
// One lock per group from the gate check to the durable obligation. The auto-commit of a
|
||||
// member's leave runs from ingest while the user may be committing a rename or an add;
|
||||
// the gate check alone let both pass before either recorded its obligation, and two
|
||||
// commits staged from one epoch fork the group.
|
||||
val (staged, event, obligation) =
|
||||
commitPreparationLock(nostrGroupId).withLock {
|
||||
requireOutboundAllowed(nostrGroupId, "commit a group-state change", ignoringGate)
|
||||
// A commit whose publish went unconfirmed leaves the group in
|
||||
// `PendingPublish`, which correctly refuses new commits — but the only
|
||||
// thing that ever resolved it was `restoreAll`, so one dropped socket
|
||||
// wedged the group until the app was restarted. Retrying this group's
|
||||
// obligations here makes the next attempt the recovery: republishing
|
||||
// the same event is safe (a peer deduplicates it by id) and a retry
|
||||
// that still fails leaves the group held exactly as before.
|
||||
if (publishGate.lifecycle(nostrGroupId) == GroupLifecycleState.PENDING_PUBLISH) {
|
||||
retryPendingPublishObligations(onlyGroupId = nostrGroupId)
|
||||
}
|
||||
check(publishGate.canPrepareLocalCommit(nostrGroupId, ignoringGate)) {
|
||||
"Group $nostrGroupId cannot prepare a local commit " +
|
||||
"(lifecycle=${publishGate.lifecycle(nostrGroupId)}, gate=${publishGate.outboundGate(nostrGroupId)})"
|
||||
}
|
||||
|
||||
val staged = stage()
|
||||
val event =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
nostrGroupId = nostrGroupId,
|
||||
commitBytes = staged.result.framedCommitBytes,
|
||||
exporterKey = staged.result.preCommitExporterSecret,
|
||||
)
|
||||
val staged = stage()
|
||||
val event =
|
||||
outboundProcessor.buildCommitEvent(
|
||||
nostrGroupId = nostrGroupId,
|
||||
commitBytes = staged.result.framedCommitBytes,
|
||||
exporterKey = staged.result.preCommitExporterSecret,
|
||||
)
|
||||
|
||||
// Durable BEFORE the publish. Publishing first would leave a crash
|
||||
// window in which peers have accepted a commit this client has no
|
||||
// memory of preparing — and on restart it would generate a
|
||||
// replacement, forking itself at the same epoch.
|
||||
val obligation =
|
||||
publishGate.prepare(
|
||||
groupId = nostrGroupId,
|
||||
staged = staged,
|
||||
outboundBytes = event.signedEvent.toJson().encodeToByteArray(),
|
||||
recipientScope = relays.map { it.url },
|
||||
)
|
||||
// Durable BEFORE the publish. Publishing first would leave a crash
|
||||
// window in which peers have accepted a commit this client has no
|
||||
// memory of preparing — and on restart it would generate a
|
||||
// replacement, forking itself at the same epoch.
|
||||
val obligation =
|
||||
publishGate.prepare(
|
||||
groupId = nostrGroupId,
|
||||
staged = staged,
|
||||
outboundBytes = event.signedEvent.toJson().encodeToByteArray(),
|
||||
recipientScope = relays.map { it.url },
|
||||
)
|
||||
Triple(staged, event, obligation)
|
||||
}
|
||||
|
||||
val confirmed =
|
||||
try {
|
||||
@@ -1548,25 +1633,51 @@ class MarmotManager(
|
||||
* targets together, since a deletion is only authorized against the message
|
||||
* it names.
|
||||
*/
|
||||
fun deletedIds(messages: List<Event>): Set<HexKey> {
|
||||
fun deletedIds(
|
||||
messages: List<Event>,
|
||||
/** The group's admins: their kind-4891 removals apply to anyone's message. */
|
||||
admins: Set<HexKey> = emptySet(),
|
||||
): Set<HexKey> {
|
||||
val authorOf = HashMap<HexKey, HexKey>(messages.size)
|
||||
val claims = ArrayList<Pair<HexKey, HexKey>>()
|
||||
val removed = HashSet<HexKey>()
|
||||
for (event in messages) {
|
||||
if (event.kind == DeletionRequestEvent.KIND) {
|
||||
for (tag in event.tags) {
|
||||
if (tag.size >= 2 && tag[0] == "e") claims.add(tag[1] to event.pubKey)
|
||||
}
|
||||
} else if (event.kind == MarmotAppEvent.KIND_REMOVE) {
|
||||
if (event.pubKey in admins) removed.addAll(adminRemovalTargets(event))
|
||||
} else {
|
||||
authorOf[event.id] = event.pubKey
|
||||
}
|
||||
}
|
||||
val deleted = HashSet<HexKey>(claims.size)
|
||||
val deleted = HashSet<HexKey>(claims.size + removed.size)
|
||||
for ((targetId, deleter) in claims) {
|
||||
if (authorOf[targetId] == deleter) deleted.add(targetId)
|
||||
}
|
||||
deleted.addAll(removed.filter { it in authorOf })
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages a kind-4891 removal names, if its author may remove them: a current
|
||||
* admin of [nostrGroupId]. Empty for anything else. White Noise sends 4891 instead of
|
||||
* kind 5 whenever the deleter is an admin (their own messages included), so without
|
||||
* this an admin's deletion from White Noise never reached us.
|
||||
*/
|
||||
fun adminRemovalTargets(
|
||||
nostrGroupId: HexKey,
|
||||
event: Event,
|
||||
): List<HexKey> {
|
||||
if (event.kind != MarmotAppEvent.KIND_REMOVE) return emptyList()
|
||||
val admins = groupView(nostrGroupId)?.adminPubkeys ?: return emptyList()
|
||||
if (event.pubKey !in admins) return emptyList()
|
||||
return adminRemovalTargets(event)
|
||||
}
|
||||
|
||||
private fun adminRemovalTargets(event: Event): List<HexKey> = event.tags.mapNotNull { tag -> if (tag.size >= 2 && tag[0] == "e") tag[1] else null }
|
||||
|
||||
/**
|
||||
* The slice of canonical group state that kind:1210 rows are derived from,
|
||||
* or null when this client is not in the group.
|
||||
@@ -2014,6 +2125,30 @@ class MarmotManager(
|
||||
}.event
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a staged departure when this account may: the step [commitPendingProposals]
|
||||
* describes, driven from ingest.
|
||||
*
|
||||
* Nothing called it, so when a White Noise member left a group whose only admin was
|
||||
* this account, the SelfRemove sat in the pool forever: the leaver stayed in the tree
|
||||
* (still able to decrypt the group), and both apps kept listing them. Only admins
|
||||
* commit here, the authority MIP-03 gives; another admin committing the same
|
||||
* proposal at the same time is an ordinary fork that convergence settles. A failure
|
||||
* is logged, not thrown: it is follow-up work, and the proposal stays staged for the
|
||||
* next attempt.
|
||||
*/
|
||||
suspend fun commitStagedProposalsIfAdmin(nostrGroupId: HexKey): OutboundGroupEvent? {
|
||||
val view = groupView(nostrGroupId) ?: return null
|
||||
if (signer.pubKey !in view.adminPubkeys) return null
|
||||
return try {
|
||||
commitPendingProposals(nostrGroupId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MarmotManager") { "could not commit staged proposals for ${nostrGroupId.take(8)}…: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disband the group: write `marmot.group.lifecycle.v1` (`0x800c`) as
|
||||
* `disbanded` in a Commit every member replays.
|
||||
@@ -2676,6 +2811,12 @@ class MarmotManager(
|
||||
// events the UI never sees directly — a disband request resolving, a
|
||||
// removal being realized.
|
||||
chatroom.outboundGate.value = publishGate.outboundGateNow(nostrGroupId)
|
||||
chatroom.isOutOfSync.value = desync.isDesynced(nostrGroupId)
|
||||
// A group we created is ours: keep it out of "New Requests" across restarts.
|
||||
// `markAsKnown` at creation is in-memory only, and a creator who has not posted
|
||||
// yet has nothing else that says so. See [isCreatedLocally] for why this is not
|
||||
// read off the tree.
|
||||
if (isCreatedLocally(nostrGroupId)) chatroom.ownerSentMessage = true
|
||||
val previousCount = chatroom.members.value.size
|
||||
val members = memberPubkeys(nostrGroupId)
|
||||
chatroom.members.value = members
|
||||
@@ -2696,6 +2837,9 @@ class MarmotManager(
|
||||
*/
|
||||
internal val GROUP_EVENT_REFETCH_OVERLAP_SEC: Long = TimeUtils.ONE_DAY.toLong()
|
||||
|
||||
/** Prefix of the inbound error for an app message no epoch here can open. */
|
||||
internal const val NO_CANONICAL_EPOCH_ERROR = "Application message decrypts on no canonical epoch"
|
||||
|
||||
/**
|
||||
* How often the settler re-checks an open pass.
|
||||
*
|
||||
|
||||
+45
-1
@@ -143,12 +143,24 @@ class MarmotSyncPolicy(
|
||||
}
|
||||
if (filterMap.isEmpty()) return
|
||||
|
||||
val events = drain(filterMap, timeoutMs)
|
||||
// Relays answer newest-first and several relays interleave, but a kind:445 can only
|
||||
// be opened at the epoch its predecessors built: a message sent after a commit fails
|
||||
// (UndecryptableOuter) if it is tried before that commit. The
|
||||
// cursor then moves past it and it is never fetched again, which is how an offline
|
||||
// member came back missing a rename and every message after a membership change.
|
||||
// Welcomes first (they create the groups), then group events oldest first.
|
||||
val events =
|
||||
drain(filterMap, timeoutMs)
|
||||
.distinctBy { it.second.id }
|
||||
.sortedWith(compareBy({ if (it.second.kind == GiftWrapEvent.KIND) 0 else 1 }, { it.second.createdAt }))
|
||||
|
||||
var maxGwSeen = gwSince ?: 0L
|
||||
val maxGroupSeen = perGroupFilters.keys.associateWith { cursors.groupSince(it) ?: 0L }.toMutableMap()
|
||||
var sawGiftWrap = false
|
||||
val sawGroupEvent = mutableSetOf<HexKey>()
|
||||
val stagedProposals = mutableSetOf<HexKey>()
|
||||
val waitingOnEpoch = mutableListOf<Event>()
|
||||
var advancedEpoch = false
|
||||
|
||||
for ((relay, event) in events) {
|
||||
// All the MLS/NIP-59 decryption + persistence lives in MarmotIngest —
|
||||
@@ -161,6 +173,9 @@ class MarmotSyncPolicy(
|
||||
else -> ""
|
||||
}
|
||||
log("ingest ${event.kind}/${event.id.take(8)} via $relay → ${result::class.simpleName}$detail")
|
||||
if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId)
|
||||
if (event.kind == GroupEvent.KIND && result.couldOpenAfterACommit()) waitingOnEpoch.add(event)
|
||||
if (result is MarmotIngestResult.Commit) advancedEpoch = true
|
||||
|
||||
when (event.kind) {
|
||||
GiftWrapEvent.KIND -> {
|
||||
@@ -177,6 +192,27 @@ class MarmotSyncPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// Same-second ties and cross-relay stragglers can still put an event ahead of the
|
||||
// commit it needs; once a commit landed, give those another go, until a pass
|
||||
// opens nothing new.
|
||||
while (advancedEpoch && waitingOnEpoch.isNotEmpty()) {
|
||||
advancedEpoch = false
|
||||
val retry = waitingOnEpoch.toList()
|
||||
waitingOnEpoch.clear()
|
||||
for (event in retry) {
|
||||
val result = marmot.ingest(event)
|
||||
log("retry ${event.kind}/${event.id.take(8)} → ${result::class.simpleName}")
|
||||
if (result is MarmotIngestResult.Commit) advancedEpoch = true
|
||||
if (result is MarmotIngestResult.ProposalStaged) stagedProposals.add(result.groupId)
|
||||
if (result.couldOpenAfterACommit()) waitingOnEpoch.add(event)
|
||||
}
|
||||
}
|
||||
|
||||
// A member's SelfRemove only takes effect once an admin commits it.
|
||||
for (gid in stagedProposals) {
|
||||
marmot.commitStagedProposalsIfAdmin(gid)?.let { log("committed staged proposals for ${gid.take(8)} → ${it.signedEvent.id.take(8)}") }
|
||||
}
|
||||
|
||||
if (sawGiftWrap && maxGwSeen > 0) {
|
||||
cursors.giftWrapSince = maxGwSeen
|
||||
}
|
||||
@@ -217,3 +253,11 @@ class MarmotSyncPolicy(
|
||||
const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed only because the epoch it was sent at isn't reached yet; a later commit may open it.
|
||||
* Only an undecryptable outer layer qualifies. A "no canonical epoch" failure already opened
|
||||
* its outer layer on an epoch we hold, so no commit can help it, and the inbound processor
|
||||
* remembers its id: a retry would only come back as a duplicate.
|
||||
*/
|
||||
private fun MarmotIngestResult.couldOpenAfterACommit(): Boolean = this is MarmotIngestResult.UndecryptableOuter
|
||||
|
||||
@@ -1752,7 +1752,15 @@ class Account(
|
||||
marmot.deleteMarmotMessages(groupId, groupNotes)
|
||||
}
|
||||
|
||||
val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null }
|
||||
val (myRumors, myNotes) =
|
||||
otherNotes
|
||||
.filter { it.author == userProfile() && it.event != null }
|
||||
.partition { it.isPrivateRumor() }
|
||||
|
||||
// Private rumors (NIP-17 DMs, private reactions) are retracted inside their own
|
||||
// conversation for the same reason, whichever caller asked for a plain delete.
|
||||
myRumors.forEach { deletePrivately(listOf(it), it) }
|
||||
|
||||
if (myNotes.isNotEmpty()) {
|
||||
// chunks in 200 elements to avoid going over the 65KB limit for events.
|
||||
myNotes.chunked(200).forEach { chunkedList ->
|
||||
@@ -4012,9 +4020,9 @@ class Account(
|
||||
// assertion by its sender and is dropped at ingest — so these are
|
||||
// safe to render with attribution.
|
||||
marmotManager.onSystemRowDerived = { groupId, row ->
|
||||
cache.justConsume(row, null, true)
|
||||
val note = cache.getOrCreateNote(row.id)
|
||||
note.event = row
|
||||
// Indexed like any inner event: a bare `note.event = row` left the row with no
|
||||
// author, which the Messages tab read as "No messages yet".
|
||||
val note = marmot.indexMarmotInnerEvent(row).note
|
||||
marmotGroupList.addMessage(groupId, note)
|
||||
}
|
||||
|
||||
@@ -4081,6 +4089,7 @@ class Account(
|
||||
// kind:1009 edit is re-linked to its message too.
|
||||
val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note
|
||||
marmotGroupList.addMessage(groupId, innerNote)
|
||||
marmot.applyMarmotAdminRemoval(groupId, innerEvent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"Account",
|
||||
|
||||
+112
-8
@@ -21,6 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.commons.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
|
||||
@@ -88,6 +91,33 @@ class AccountMarmotActions(
|
||||
/** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */
|
||||
private var lastOwnerCheck: Pair<Long, LatestKeyPackageOwner>? = null
|
||||
|
||||
// Welcome rumor ids with a retry already scheduled, so a relay re-delivering the same
|
||||
// wrap while one waits does not start a second chain of retries.
|
||||
private val welcomeRetries = mutableSetOf<HexKey>()
|
||||
|
||||
// Welcome rumor ids whose retries ran out this session. Relays re-deliver two days of gift
|
||||
// wraps on every re-subscription, and each delivery used to start a fresh chain, so a
|
||||
// Welcome that can never apply was re-run through MLS for as long as relays kept it.
|
||||
// In memory on purpose: the next app start still gives it one more try.
|
||||
private val welcomeRetriesExhausted = mutableSetOf<HexKey>()
|
||||
private val welcomeRetriesLock = KmpLock()
|
||||
|
||||
/** True when [welcomeId] had no retry pending, has retries left, and now has one pending. */
|
||||
fun claimWelcomeRetry(welcomeId: HexKey): Boolean =
|
||||
welcomeRetriesLock.withLock {
|
||||
welcomeId !in welcomeRetriesExhausted && welcomeRetries.add(welcomeId)
|
||||
}
|
||||
|
||||
fun markWelcomeRetriesExhausted(welcomeId: HexKey) {
|
||||
welcomeRetriesLock.withLock { welcomeRetriesExhausted.add(welcomeId) }
|
||||
}
|
||||
|
||||
fun welcomeRetriesExhausted(welcomeId: HexKey): Boolean = welcomeRetriesLock.withLock { welcomeId in welcomeRetriesExhausted }
|
||||
|
||||
fun releaseWelcomeRetry(welcomeId: HexKey) {
|
||||
welcomeRetriesLock.withLock { welcomeRetries.remove(welcomeId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the relay set for a Marmot group. Prefer the relays carried in
|
||||
* the MLS GroupContext metadata so every member converges on the same
|
||||
@@ -128,12 +158,11 @@ class AccountMarmotActions(
|
||||
nostrGroupId: HexKey,
|
||||
innerEvent: Event,
|
||||
) {
|
||||
// wasVerified=true: MIP-03 inner events are unsigned rumors, so
|
||||
// Schnorr verification would reject every one. This one we built
|
||||
// ourselves, which is as authenticated as it gets.
|
||||
val isNew = account.cache.justConsume(innerEvent, null, true)
|
||||
val innerNote = account.cache.getOrCreateNote(innerEvent.id)
|
||||
if (isNew) innerNote.event = innerEvent
|
||||
// The same indexing as a received message. A bare justConsume returns false for a kind
|
||||
// LocalCache does not dispatch (push-token lists 447-449, edits, stream starts), which
|
||||
// left our own copy an EVENTLESS note in the conversation: an "Event is loading or can't
|
||||
// be found" row for every push-token answer we sent.
|
||||
val innerNote = indexMarmotInnerEvent(innerEvent).note
|
||||
account.marmotGroupList.addMessage(nostrGroupId, innerNote)
|
||||
// Sending a message moves the group out of "New Requests" into
|
||||
// "Known" — do this eagerly before the relay round-trip so the UI
|
||||
@@ -284,6 +313,21 @@ class AccountMarmotActions(
|
||||
return IndexedInnerEvent(innerNote, isNew)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a kind-4891 admin removal: drop the messages it names from the conversation
|
||||
* when its author is an admin of the group (see [MarmotManager.adminRemovalTargets]).
|
||||
* Runs on live delivery and on the restart replay, which re-adds every stored message.
|
||||
*/
|
||||
fun applyMarmotAdminRemoval(
|
||||
nostrGroupId: HexKey,
|
||||
innerEvent: Event,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
manager.adminRemovalTargets(nostrGroupId, innerEvent).forEach { targetId ->
|
||||
account.marmotGroupList.applyAdminRemoval(nostrGroupId, targetId, account.cache.getNoteIfExists(targetId))
|
||||
}
|
||||
}
|
||||
|
||||
/** [note] holds the inner event; [isNew] is true the first time this client indexed it. */
|
||||
class IndexedInnerEvent(
|
||||
val note: Note,
|
||||
@@ -591,6 +635,21 @@ class AccountMarmotActions(
|
||||
lastOwnerCheck = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this device's copy of a group it has fallen out of sync with, and make sure a
|
||||
* fresh KeyPackage is out there for the admin's re-invite. See
|
||||
* [MarmotManager.resetOutOfSyncGroup].
|
||||
*/
|
||||
suspend fun resetOutOfSyncMarmotGroup(nostrGroupId: HexKey) {
|
||||
val manager = account.marmotManager ?: return
|
||||
manager.resetOutOfSyncGroup(nostrGroupId)
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
chatroom.isOutOfSync.value = false
|
||||
chatroom.awaitingReinvite.value = true
|
||||
account.marmotGroupList.notifyGroupChanged(nostrGroupId)
|
||||
ensureMarmotKeyPackagePublished()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local user has at least one active KeyPackage bundle and
|
||||
* a published KeyPackage event on relays. Called from [init] after
|
||||
@@ -769,6 +828,18 @@ class AccountMarmotActions(
|
||||
// Creator owns the group — mark it as "known" immediately so it
|
||||
// doesn't appear under "New Requests" before the first message.
|
||||
account.marmotGroupList.markAsKnown(nostrGroupId)
|
||||
syncAndNotify(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the group's current MLS state (name, admins, members, relays) into its
|
||||
* chatroom and tell the list to re-render it. For actions that commit outside the
|
||||
* paths here which already sync.
|
||||
*/
|
||||
fun syncAndNotify(nostrGroupId: HexKey) {
|
||||
val manager = account.marmotManager ?: return
|
||||
manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId))
|
||||
account.marmotGroupList.notifyGroupChanged(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -951,13 +1022,40 @@ class AccountMarmotActions(
|
||||
*
|
||||
* The endpoints come from the account's own Blossom server list, because a
|
||||
* policy naming servers the uploader does not use would describe a group
|
||||
* nobody can actually post media to.
|
||||
* nobody can actually post media to. An account without one uploads to the
|
||||
* default list (the same one the upload picker offers and falls back
|
||||
* through), so that is what its policy names.
|
||||
*/
|
||||
fun marmotMediaPolicyServers(): List<String> =
|
||||
account.blossomServers.flow.value
|
||||
.ifEmpty {
|
||||
account.blossomServers.hostNameFlow.value
|
||||
.filter { it.type == ServerType.Blossom }
|
||||
.map { it.baseUrl }
|
||||
}.mapNotNull { normalizedPolicyBaseUrl(it) }
|
||||
.distinct()
|
||||
.take(EncryptedMediaPolicyV2.MAX_ENTRIES)
|
||||
|
||||
/**
|
||||
* [url] in the byte-exact form the media policy requires, or null when it has none.
|
||||
* The component rejects a base URL that isn't its own WHATWG serialization, so the
|
||||
* everyday spelling without a trailing slash (`https://cdn.nostrcheck.me`) failed the
|
||||
* whole commit.
|
||||
*/
|
||||
private fun normalizedPolicyBaseUrl(url: String): String? =
|
||||
try {
|
||||
MarmotWebUrl.normalize(url, allowHttp = true, label = "base_url").also {
|
||||
EncryptedMediaPolicyV2.requireNormalizedBaseUrl(it)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val servers = account.blossomServers.flow.value
|
||||
val servers = marmotMediaPolicyServers()
|
||||
require(servers.isNotEmpty()) {
|
||||
"Cannot enable encrypted media without at least one Blossom server configured"
|
||||
}
|
||||
@@ -1018,6 +1116,9 @@ class AccountMarmotActions(
|
||||
if (view.adminPubkeys.contains(targetPubKey)) return
|
||||
|
||||
manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList())
|
||||
// The commit is canonical once published; without this the roster and the
|
||||
// admin badges keep the pre-commit admin list until our own echo or a restart.
|
||||
manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1042,5 +1143,8 @@ class AccountMarmotActions(
|
||||
}
|
||||
|
||||
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
|
||||
// The commit is canonical once published; without this the roster and the
|
||||
// admin badges keep the pre-commit admin list until our own echo or a restart.
|
||||
manager.syncMetadataTo(nostrGroupId, account.marmotGroupList.getOrCreateGroup(nostrGroupId))
|
||||
}
|
||||
}
|
||||
|
||||
+71
-3
@@ -54,6 +54,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
@@ -393,11 +397,17 @@ class GiftWrapEventHandler(
|
||||
private suspend fun processMarmotWelcomeFlow(
|
||||
innerEvent: Event,
|
||||
account: Account,
|
||||
attempt: Int = 0,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (innerEvent !is WelcomeEvent) {
|
||||
return
|
||||
}
|
||||
// A Welcome that already joined (or never can) is done. Replays of its wrap are routine:
|
||||
// every re-subscription re-delivers the last two days of gift wraps.
|
||||
if (manager.isTerminallyIngested(innerEvent.id)) return
|
||||
// Out of retries this session: a re-delivered wrap waits for the next app start.
|
||||
if (attempt == 0 && account.marmot.welcomeRetriesExhausted(innerEvent.id)) return
|
||||
|
||||
// "h" tag is optional per MIP-02 — some senders (e.g. whitenoise-rs) omit it.
|
||||
// nostrGroupId is derived from the MLS GroupContext's NostrGroupData extension instead.
|
||||
@@ -406,12 +416,14 @@ private suspend fun processMarmotWelcomeFlow(
|
||||
|
||||
when (result) {
|
||||
is WelcomeResult.Joined -> {
|
||||
manager.markTerminallyIngested(innerEvent.id)
|
||||
Log.d("MarmotDbg") {
|
||||
"processMarmotWelcomeFlow: Joined ${result.nostrGroupId.take(8)}… needsKeyPackageRotation=${result.needsKeyPackageRotation}"
|
||||
}
|
||||
|
||||
// Sync MIP-01 metadata from group extensions to chatroom
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId)
|
||||
chatroom.awaitingReinvite.value = false
|
||||
manager.syncMetadataTo(result.nostrGroupId, chatroom)
|
||||
Log.d("MarmotDbg") {
|
||||
"processMarmotWelcomeFlow: synced metadata name=${chatroom.displayName.value} " +
|
||||
@@ -436,6 +448,7 @@ private suspend fun processMarmotWelcomeFlow(
|
||||
}
|
||||
|
||||
is WelcomeResult.AlreadyJoined -> {
|
||||
manager.markTerminallyIngested(innerEvent.id)
|
||||
// Benign replay of a gift-wrapped Welcome (kind:1059) we already
|
||||
// processed in a prior session — the relay is just re-delivering
|
||||
// it after app restart. Log at DEBUG, not WARN.
|
||||
@@ -445,11 +458,42 @@ private suspend fun processMarmotWelcomeFlow(
|
||||
}
|
||||
|
||||
is WelcomeResult.Error -> {
|
||||
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" }
|
||||
Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR (attempt ${attempt + 1}) ${result.message}" }
|
||||
if (result.message.contains("No matching KeyPackageBundle")) {
|
||||
// Bundles are generated before their KeyPackage is published, so a Welcome
|
||||
// for one we never held can never become processable.
|
||||
manager.markTerminallyIngested(innerEvent.id)
|
||||
} else {
|
||||
scheduleWelcomeRetry(innerEvent, account, attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff for a Welcome that failed for a reason that can pass (the signer was busy, the
|
||||
* KeyPackage store was still loading, a relay round trip failed). Before this a failed
|
||||
* Welcome was only retried on the next app start: a replayed wrap skipped the flow.
|
||||
*/
|
||||
private val WELCOME_RETRY_DELAYS_MS = longArrayOf(30_000L, 120_000L, 600_000L)
|
||||
|
||||
private fun scheduleWelcomeRetry(
|
||||
welcome: WelcomeEvent,
|
||||
account: Account,
|
||||
attempt: Int,
|
||||
) {
|
||||
if (attempt >= WELCOME_RETRY_DELAYS_MS.size) {
|
||||
account.marmot.markWelcomeRetriesExhausted(welcome.id)
|
||||
return
|
||||
}
|
||||
if (!account.marmot.claimWelcomeRetry(welcome.id)) return
|
||||
account.scope.launch(Dispatchers.IO) {
|
||||
delay(WELCOME_RETRY_DELAYS_MS[attempt])
|
||||
account.marmot.releaseWelcomeRetry(welcome.id)
|
||||
processMarmotWelcomeFlow(welcome, account, attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
class SealEventHandler(
|
||||
private val account: Account,
|
||||
private val cache: LocalCache,
|
||||
@@ -528,7 +572,13 @@ class SealEventHandler(
|
||||
cache.copyRelaysFromTo(publicNote, rumorId)
|
||||
val innerRumorNote = cache.getOrCreateNote(rumorId)
|
||||
innerRumorNote.event?.let { innerRumor ->
|
||||
eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote)
|
||||
// A re-delivered Welcome goes back through the MLS flow, which returns at once
|
||||
// when it already joined; one that failed the first time gets another chance.
|
||||
if (MarmotInboundProcessor.isWelcomeEvent(innerRumor)) {
|
||||
processMarmotWelcomeFlow(innerRumor, account)
|
||||
} else {
|
||||
eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,6 +696,10 @@ class GroupEventHandler(
|
||||
|
||||
when (result) {
|
||||
is GroupEventResult.ApplicationMessage -> {
|
||||
// A message that decrypts means this device is back in step.
|
||||
account.marmotGroupList
|
||||
.getOrCreateGroup(result.groupId)
|
||||
.isOutOfSync.value = false
|
||||
// Parse the inner event JSON and index it
|
||||
val innerEvent = Event.fromJson(result.innerEventJson)
|
||||
Log.d("MarmotDbg") {
|
||||
@@ -722,6 +776,7 @@ class GroupEventHandler(
|
||||
// peer-sent kind:1210 is dropped inside addMessage — see
|
||||
// `MarmotGroupList.isDisplayableFeedMessage`.
|
||||
account.marmotGroupList.addMessage(result.groupId, innerNote)
|
||||
account.marmot.applyMarmotAdminRemoval(result.groupId, innerEvent)
|
||||
|
||||
// Traffic is the natural clock for disappearing messages: a
|
||||
// group being read is a group whose expired messages should
|
||||
@@ -826,6 +881,14 @@ class GroupEventHandler(
|
||||
Log.d("MarmotDbg") {
|
||||
"GroupEventHandler.add: ProposalStaged group=${result.groupId.take(8)}… senderLeaf=${result.senderLeafIndex}"
|
||||
}
|
||||
// A departure only takes effect once an admin commits it; if that is us, do it.
|
||||
val groupId = result.groupId
|
||||
account.scope.launch(Dispatchers.IO) {
|
||||
if (manager.commitStagedProposalsIfAdmin(groupId) != null) {
|
||||
manager.syncMetadataTo(groupId, account.marmotGroupList.getOrCreateGroup(groupId))
|
||||
account.marmotGroupList.notifyGroupChanged(groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GroupEventResult.AppMessageOnCandidateBranch -> {
|
||||
@@ -852,7 +915,12 @@ class GroupEventHandler(
|
||||
}
|
||||
|
||||
is GroupEventResult.Error -> {
|
||||
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" }
|
||||
Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR group=${result.groupId?.take(8)} ${result.message}" }
|
||||
result.groupId?.let { groupId ->
|
||||
val outOfSync = manager.isOutOfSync(groupId)
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(groupId)
|
||||
if (chatroom.isOutOfSync.value != outOfSync) chatroom.isOutOfSync.value = outOfSync
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
+6
@@ -90,6 +90,12 @@ class MarmotGroupChatroom(
|
||||
*/
|
||||
var isCurrentProfile = MutableStateFlow(true)
|
||||
|
||||
/** This device can no longer read what the other members send (see MarmotDesyncDetector). */
|
||||
val isOutOfSync = MutableStateFlow(false)
|
||||
|
||||
/** This device dropped its copy of the group and waits for an admin to add it back. */
|
||||
val awaitingReinvite = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True once the group carries the `encrypted-media-v2` policy (`0x800b`).
|
||||
*
|
||||
|
||||
+25
-1
@@ -38,6 +38,12 @@ class MarmotGroupList(
|
||||
|
||||
private val noteToGroupIndex = LargeCache<HexKey, HexKey>()
|
||||
|
||||
// Message id -> group, for every message a group admin removed (kind 4891). A removal can
|
||||
// decrypt before its target: a catch-up returns newest first, and both sit in one epoch.
|
||||
// Unlike a kind-5 there is no LocalCache deletion index behind it, so without this record a
|
||||
// target arriving second would be added and stay, restarts included.
|
||||
private val adminRemovedIds = LargeCache<HexKey, HexKey>()
|
||||
|
||||
private val _groupListChanges = MutableSharedFlow<HexKey>(0, 20, BufferOverflow.DROP_OLDEST)
|
||||
val groupListChanges = _groupListChanges
|
||||
|
||||
@@ -48,6 +54,7 @@ class MarmotGroupList(
|
||||
msg: Note,
|
||||
) {
|
||||
if (!isDisplayableFeedMessage(msg)) return
|
||||
if (adminRemovedIds.get(msg.idHex) == nostrGroupId) return
|
||||
val chatroom = getOrCreateGroup(nostrGroupId)
|
||||
if (chatroom.addMessageSync(msg)) {
|
||||
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
|
||||
@@ -99,6 +106,19 @@ class MarmotGroupList(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an admin removal of [targetId] in [nostrGroupId]: drops [target] if it is already
|
||||
* shown, and keeps the removal so the message is refused if it arrives later.
|
||||
*/
|
||||
fun applyAdminRemoval(
|
||||
nostrGroupId: HexKey,
|
||||
targetId: HexKey,
|
||||
target: Note?,
|
||||
) {
|
||||
adminRemovedIds.put(targetId, nostrGroupId)
|
||||
if (target != null) removeMessage(nostrGroupId, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a group from the in-memory list. Also clears the chatroom's own
|
||||
* message set and the note→group index: LocalCache holds notes weakly, so
|
||||
@@ -146,7 +166,9 @@ class MarmotGroupList(
|
||||
* authorship rule below.
|
||||
*/
|
||||
private fun isDisplayableFeedMessage(msg: Note): Boolean {
|
||||
val kind = msg.event?.kind ?: return true
|
||||
// Every path here indexes the decrypted inner event onto its note first, so a note
|
||||
// without one can't be classified and would only render as a "can't be found" row.
|
||||
val kind = msg.event?.kind ?: return false
|
||||
if (kind == MARMOT_INNER_KIND_SYSTEM_ROW) return isOwnDerivedSystemRow(msg)
|
||||
return kind !in NON_CHAT_INNER_KINDS
|
||||
}
|
||||
@@ -175,6 +197,7 @@ class MarmotGroupList(
|
||||
|
||||
companion object {
|
||||
private const val MARMOT_INNER_KIND_DELETION = 5
|
||||
private const val MARMOT_INNER_KIND_ADMIN_REMOVAL = 4891
|
||||
private const val MARMOT_INNER_KIND_REACTION = 7
|
||||
private const val MARMOT_INNER_KIND_EDIT = 1009
|
||||
private const val MARMOT_INNER_KIND_STREAM_START = 1200
|
||||
@@ -190,6 +213,7 @@ class MarmotGroupList(
|
||||
private val NON_CHAT_INNER_KINDS =
|
||||
setOf(
|
||||
MARMOT_INNER_KIND_DELETION,
|
||||
MARMOT_INNER_KIND_ADMIN_REMOVAL,
|
||||
MARMOT_INNER_KIND_REACTION,
|
||||
MARMOT_INNER_KIND_EDIT,
|
||||
MARMOT_INNER_KIND_STREAM_START,
|
||||
|
||||
+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")
|
||||
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.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
data class UploadResult(
|
||||
val blossom: BlossomUploadResult,
|
||||
@@ -76,6 +79,8 @@ class UploadOrchestrator(
|
||||
quality: CompressionQuality? = null,
|
||||
bypassReencode: Boolean = false,
|
||||
preCompressed: File? = null,
|
||||
// Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting].
|
||||
fallbackServerBaseUrls: List<String> = emptyList(),
|
||||
): UploadResult {
|
||||
var reencodedTemp: File? = null
|
||||
var strippedTemp: File? = null
|
||||
@@ -133,12 +138,14 @@ class UploadOrchestrator(
|
||||
|
||||
// 5. Upload.
|
||||
val result =
|
||||
client.upload(
|
||||
file = finalFile,
|
||||
contentType = metadata.mimeType,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server ->
|
||||
client.upload(
|
||||
file = finalFile,
|
||||
contentType = metadata.mimeType,
|
||||
serverBaseUrl = server,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
}
|
||||
|
||||
return UploadResult(blossom = result, metadata = metadata)
|
||||
} finally {
|
||||
@@ -171,6 +178,8 @@ class UploadOrchestrator(
|
||||
// When true it's uploaded with the real media type — less private, but
|
||||
// required by strict Blossom servers that reject octet-stream (HTTP 415).
|
||||
declareRealMimeType: Boolean = false,
|
||||
// Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting].
|
||||
fallbackServerBaseUrls: List<String> = emptyList(),
|
||||
): EncryptedUploadResult {
|
||||
var reencodedTemp: File? = null
|
||||
var strippedTemp: File? = null
|
||||
@@ -230,13 +239,17 @@ class UploadOrchestrator(
|
||||
// 415) also work, at the cost of leaking the media category.
|
||||
val uploadContentType =
|
||||
if (declareRealMimeType) metadata.mimeType else "application/octet-stream"
|
||||
// The same ciphertext goes to every server tried, so the cipher and the
|
||||
// encrypted hash stay valid wherever it lands.
|
||||
val result =
|
||||
client.upload(
|
||||
bytes = encrypted,
|
||||
contentType = uploadContentType,
|
||||
serverBaseUrl = serverBaseUrl,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server ->
|
||||
client.upload(
|
||||
bytes = encrypted,
|
||||
contentType = uploadContentType,
|
||||
serverBaseUrl = server,
|
||||
authHeader = authHeader,
|
||||
)
|
||||
}
|
||||
|
||||
return EncryptedUploadResult(
|
||||
blossom = result,
|
||||
@@ -251,4 +264,31 @@ class UploadOrchestrator(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [serverBaseUrl] first, then each of [fallbacks] (skipping duplicates by domain) until
|
||||
* one accepts. Servers differ in what they take -- several answer an opaque encrypted
|
||||
* blob with 415, paid ones with 402 -- and the user can't see that in advance. The auth
|
||||
* header is not server-scoped, so the same one serves every attempt. When all fail, the
|
||||
* first server's error is thrown: that is the server the user chose.
|
||||
*/
|
||||
private suspend fun uploadToFirstAccepting(
|
||||
serverBaseUrl: String,
|
||||
fallbacks: List<String>,
|
||||
upload: suspend (String) -> BlossomUploadResult,
|
||||
): BlossomUploadResult {
|
||||
val seen = mutableSetOf<String>()
|
||||
val order = (listOf(serverBaseUrl) + fallbacks).filter { seen.add(BlossomServerUrl.domain(it)) }
|
||||
var firstError: Exception? = null
|
||||
for (server in order) {
|
||||
try {
|
||||
return upload(server)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (firstError == null) firstError = e
|
||||
Log.w("UploadOrchestrator") { "Upload to $server failed (${e.message}), trying the next server" }
|
||||
}
|
||||
}
|
||||
throw firstError!!
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.quartz.marmot.GroupEventResult
|
||||
import com.vitorpamplona.quartz.marmot.InMemoryIngestDedupStore
|
||||
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
|
||||
@@ -260,6 +262,81 @@ class MarmotDisbandTest {
|
||||
assertEquals(LocalOutboundGate.DISBANDING, chatroom.outboundGate.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group we created stays Known after a restart, an invitation does not`() =
|
||||
runBlocking {
|
||||
// "Known" at creation was an in-memory flag, so after a restart a creator who
|
||||
// had not posted yet found their own group under New Requests. The sync that
|
||||
// runs on restore has to reach the same answer from the MLS state alone.
|
||||
val alice = Fixture()
|
||||
val bob = Fixture()
|
||||
alice.createCurrentProfile()
|
||||
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
|
||||
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
|
||||
bob.manager.ingest(welcome!!.giftWrapEvent)
|
||||
|
||||
val alicesRoom = MarmotGroupChatroom(nostrGroupId)
|
||||
val bobsRoom = MarmotGroupChatroom(nostrGroupId)
|
||||
alice.manager.syncMetadataTo(nostrGroupId, alicesRoom)
|
||||
bob.manager.syncMetadataTo(nostrGroupId, bobsRoom)
|
||||
|
||||
assertTrue(alicesRoom.isKnown(emptySet()), "the creator's own group")
|
||||
assertTrue(!bobsRoom.isKnown(emptySet()), "an invitation from someone we don't follow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member reset after falling out of sync is back in once re-added`() =
|
||||
runBlocking {
|
||||
// The recovery for a device stuck on a dead epoch: it drops its copy without
|
||||
// publishing anything, an admin removes and re-adds it, and the new Welcome
|
||||
// joins instead of being taken for a replay of a group it still holds.
|
||||
val alice = Fixture()
|
||||
val bob = Fixture()
|
||||
alice.createCurrentProfile()
|
||||
val kp = bob.manager.generateKeyPackageEvent(relays = emptyList())
|
||||
val (_, welcome) = alice.manager.addMember(nostrGroupId, kp, emptyList())
|
||||
bob.manager.ingest(welcome!!.giftWrapEvent)
|
||||
|
||||
bob.manager.resetOutOfSyncGroup(nostrGroupId)
|
||||
assertNull(bob.manager.groupState(nostrGroupId), "the stale copy is gone")
|
||||
|
||||
val bobLeaf =
|
||||
alice.manager
|
||||
.memberPubkeys(nostrGroupId)
|
||||
.first { it.pubkey == bob.signer.pubKey }
|
||||
.leafIndex
|
||||
alice.manager.removeMember(nostrGroupId, bobLeaf)
|
||||
val freshKp = bob.manager.generateKeyPackageEvent(relays = emptyList())
|
||||
val (_, reinvite) = alice.manager.addMember(nostrGroupId, freshKp, emptyList())
|
||||
val joined = bob.manager.ingest(reinvite!!.giftWrapEvent)
|
||||
assertTrue(joined is MarmotIngestResult.JoinedGroup, "re-invite joins, got $joined")
|
||||
|
||||
val hello = alice.manager.buildTextMessage(nostrGroupId, "welcome back")
|
||||
val received = bob.manager.processGroupEvent(hello.outbound.signedEvent)
|
||||
assertTrue(received is GroupEventResult.ApplicationMessage, "and reads the group again, got $received")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group we created is still ours after a restart`() =
|
||||
runBlocking {
|
||||
// The marker is what survives, not the tree position: a fresh manager on the same
|
||||
// stores has to find it on restore.
|
||||
val signer = NostrSignerInternal(KeyPair())
|
||||
val states = SnapshotStateStore()
|
||||
val messages = SnapshotMessageStore()
|
||||
val bundles = SnapshotBundleStore()
|
||||
val markers = InMemoryIngestDedupStore()
|
||||
val before = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers)
|
||||
before.createCurrentProfileGroup(nostrGroupId, listOf("wss://relay.invalid"), GroupProfileV1("mine", ""))
|
||||
|
||||
val after = MarmotManager(signer, states, messages, bundles, publisher = ACCEPTING_RELAY, ingestDedupStore = markers)
|
||||
after.restoreAll()
|
||||
val room = MarmotGroupChatroom(nostrGroupId)
|
||||
after.syncMetadataTo(nostrGroupId, room)
|
||||
|
||||
assertTrue(room.isKnown(emptySet()), "the creator's own group, after a restart")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejoining a group we left clears the departure gate`() =
|
||||
runBlocking {
|
||||
|
||||
+42
@@ -140,6 +140,48 @@ class MarmotEditsAndSystemRowsTest {
|
||||
assertTrue(mine.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an admin's kind 4891 removal retracts a message, a non-admin's does not`() =
|
||||
runBlocking {
|
||||
// White Noise sends 4891 instead of kind 5 whenever the deleter is an admin,
|
||||
// for their own messages too; missing it meant those deletions never applied.
|
||||
val f = Fixture()
|
||||
f.manager.createGroup(
|
||||
nostrGroupId,
|
||||
MarmotGroupData(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "moderated",
|
||||
relays = listOf("wss://relay.invalid"),
|
||||
adminPubkeys = listOf(f.signer.pubKey),
|
||||
),
|
||||
)
|
||||
val target = f.manager.buildTextMessage(nostrGroupId, "moderate me")
|
||||
val admin = f.signer.pubKey
|
||||
val stranger = "f".repeat(64)
|
||||
|
||||
fun removal(by: String) =
|
||||
MarmotAppEvent.build(
|
||||
pubKey = by,
|
||||
kind = MarmotAppEvent.KIND_REMOVE,
|
||||
content = """{"v":1,"action":"remove"}""",
|
||||
createdAt = 1_800_000_000L,
|
||||
tags = arrayOf(arrayOf("e", target.innerEvent.id)),
|
||||
)
|
||||
val byStranger = removal(stranger)
|
||||
f.manager.persistDecryptedMessage(nostrGroupId, byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}")
|
||||
val admins = setOf(admin)
|
||||
assertTrue(target.innerEvent.id !in f.manager.deletedIds(f.manager.storedEvents(), admins))
|
||||
assertTrue(f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byStranger.toJson().dropLast(1) + ",\"sig\":\"\"}")).isEmpty())
|
||||
|
||||
val byAdmin = removal(admin)
|
||||
f.manager.persistDecryptedMessage(nostrGroupId, byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}")
|
||||
assertTrue(target.innerEvent.id in f.manager.deletedIds(f.manager.storedEvents(), admins))
|
||||
assertEquals(
|
||||
listOf(target.innerEvent.id),
|
||||
f.manager.adminRemovalTargets(nostrGroupId, Event.fromJson(byAdmin.toJson().dropLast(1) + ",\"sig\":\"\"}")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one kind 5 retracts every message it names`() =
|
||||
runBlocking {
|
||||
|
||||
+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
|
||||
fun `every member applies the commit that evicts the leaver, not just the committer`() =
|
||||
runBlocking {
|
||||
|
||||
@@ -3021,7 +3021,7 @@
|
||||
<string name="upload_error_voice_message_exception">فشل رفع الصوت: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">فشل رفع الرسالة الصوتية</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 غير مدعوم للرسائل الصوتية بعد</string>
|
||||
<string name="uptime">%1$d%% وقت التشغيل</string>
|
||||
<string name="uptime">%1$d% وقت التشغيل</string>
|
||||
<string name="use_internal_tor">محرك Tor النشط</string>
|
||||
<string name="use_internal_tor_explainer">استخدم الإصدار الداخلي أو Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">لا يمتلك المستخدم عنوان (Lightning Address) لاستقبال sats</string>
|
||||
|
||||
@@ -2897,7 +2897,7 @@
|
||||
<string name="upload_error_voice_message_exception">ভয়েস আপলোড ব্যর্থ হয়েছে: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">ভয়েস বার্তা আপলোড করতে ব্যর্থ হয়েছে</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 এখনও ভয়েস বার্তার জন্য সমর্থিত নয়</string>
|
||||
<string name="uptime">%1$d%% আপটাইম</string>
|
||||
<string name="uptime">%1$d% আপটাইম</string>
|
||||
<string name="use_internal_tor">সক্রিয় Tor ইঞ্জিন</string>
|
||||
<string name="use_internal_tor_explainer">অভ্যন্তরীণ সংস্করণ বা Orbot ব্যবহার করুন</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই</string>
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Přidejte alespoň jednu keš.</string>
|
||||
<string name="geocache_publish_failed">Zveřejnění se nezdařilo. Zkontrolujte připojení k relayům a zkuste to znovu.</string>
|
||||
<string name="podcast_value_zap_split_hint">Zapy na toto se rozdělují mezi:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="relay_monitor_rtt_open">Otevření</string>
|
||||
<string name="relay_monitor_rtt_read">Čtení</string>
|
||||
@@ -3114,7 +3114,7 @@
|
||||
<item quantity="other">%1$d relayů</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">odběry subscriptions filtry relaye relay požadavky reqs připojení proč diagnostika</string>
|
||||
<string name="active_subs_share">%1$d %% ze všech</string>
|
||||
<string name="active_subs_share">%1$d % ze všech</string>
|
||||
<string name="active_subs_title">Aktivní odběry relayů</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtr zatím není přiřazen</item>
|
||||
@@ -5352,7 +5352,7 @@
|
||||
<string name="upload_error_voice_message_exception">Nahrávání hlasu selhalo: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Nepodařilo se nahrát hlasovou zprávu</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 zatím není pro hlasové zprávy podporován</string>
|
||||
<string name="uptime">%1$d%% dostupnost</string>
|
||||
<string name="uptime">%1$d% dostupnost</string>
|
||||
<string name="use_internal_tor">Aktivní Tor Engine</string>
|
||||
<string name="use_internal_tor_explainer">Použijte interní verzi nebo Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel nemá nastavenou LN adresu pro přijímání sats</string>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Füge mindestens einen Cache hinzu.</string>
|
||||
<string name="geocache_publish_failed">Veröffentlichen fehlgeschlagen. Prüfe deine Relay-Verbindung und versuche es erneut.</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps hierauf werden aufgeteilt zwischen:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="relay_monitor_rtt_open">Öffnen</string>
|
||||
<string name="relay_monitor_rtt_read">Lesen</string>
|
||||
@@ -3088,7 +3088,7 @@
|
||||
<item quantity="other">%1$d Relays</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">abonnements subscriptions filter relays anfragen reqs verbindungen warum diagnose</string>
|
||||
<string name="active_subs_share">%1$d %% von allen</string>
|
||||
<string name="active_subs_share">%1$d % von allen</string>
|
||||
<string name="active_subs_title">Aktive Relay-Abonnements</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d Filter ist noch nicht zugeordnet</item>
|
||||
@@ -5136,7 +5136,7 @@
|
||||
<string name="upload_error_voice_message_exception">Sprach-Upload fehlgeschlagen: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Sprachnachricht konnte nicht hochgeladen werden</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wird für Sprachnachrichten noch nicht unterstützt</string>
|
||||
<string name="uptime">%1$d%% Verfügbarkeit</string>
|
||||
<string name="uptime">%1$d% Verfügbarkeit</string>
|
||||
<string name="use_internal_tor">Aktiver Tor-Engine</string>
|
||||
<string name="use_internal_tor_explainer">Verwende die interne Version oder Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
|
||||
|
||||
@@ -2988,7 +2988,7 @@
|
||||
<string name="upload_error_voice_message_exception">Sprach-Upload fehlgeschlagen: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Sprachnachricht konnte nicht hochgeladen werden</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wird für Sprachnachrichten noch nicht unterstützt</string>
|
||||
<string name="uptime">%1$d%% Verfügbarkeit</string>
|
||||
<string name="uptime">%1$d% Verfügbarkeit</string>
|
||||
<string name="use_internal_tor">Aktiver Tor-Engine</string>
|
||||
<string name="use_internal_tor_explainer">Verwenden Sie die interne Version oder Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Der Benutzer hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
|
||||
|
||||
@@ -2846,7 +2846,7 @@
|
||||
<string name="upload_error_voice_message_exception">Αποτυχία ανεβάσματος φωνητικού: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Αποτυχία ανεβάσματος φωνητικού μηνύματος</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">Το NIP-95 δεν υποστηρίζεται ακόμα για φωνητικά μηνύματα</string>
|
||||
<string name="uptime">%1$d%% λειτουργία</string>
|
||||
<string name="uptime">%1$d% λειτουργία</string>
|
||||
<string name="use_internal_tor">Ενεργή μηχανή Tor</string>
|
||||
<string name="use_internal_tor_explainer">Χρησιμοποιήστε την εσωτερική έκδοση ή το Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Ο χρήστης δεν έχει ορίσει διεύθυνση LN για να λαμβάνει sats</string>
|
||||
|
||||
@@ -2901,7 +2901,7 @@
|
||||
<string name="upload_error_voice_message_exception">Voĉa alŝuto malsukcesis: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Malsukcesis alŝuti voĉan mesaĝon</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj</string>
|
||||
<string name="uptime">%1$d%% funkcia tempo</string>
|
||||
<string name="uptime">%1$d% funkcia tempo</string>
|
||||
<string name="use_internal_tor">Aktiva Tor-Motoro</string>
|
||||
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uzanto ne havas lightning-adreson por ricevi satojn</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">Voĉa alŝuto malsukcesis: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Malsukcesis alŝuti voĉan mesaĝon</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ankoraŭ ne estas subtenata por voĉaj mesaĝoj</string>
|
||||
<string name="uptime">%1$d%% funkcia tempo</string>
|
||||
<string name="uptime">%1$d% funkcia tempo</string>
|
||||
<string name="use_internal_tor">Aktiva Tor-Motoro</string>
|
||||
<string name="use_internal_tor_explainer">Uzu la internan version aŭ Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uzanto ne havas lightning-adreson por ricevi satojn</string>
|
||||
|
||||
@@ -2915,7 +2915,7 @@
|
||||
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidad</string>
|
||||
<string name="uptime">%1$d% de disponibilidad</string>
|
||||
<string name="use_internal_tor">Motor de Tor activo</string>
|
||||
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene una configuración de dirección Lightning para recibir sats</string>
|
||||
|
||||
@@ -2908,7 +2908,7 @@
|
||||
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidad</string>
|
||||
<string name="uptime">%1$d% de disponibilidad</string>
|
||||
<string name="use_internal_tor">Motor de Tor activo</string>
|
||||
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene configurada una dirección de Lightning para recibir sats</string>
|
||||
|
||||
@@ -2908,7 +2908,7 @@
|
||||
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidad</string>
|
||||
<string name="uptime">%1$d% de disponibilidad</string>
|
||||
<string name="use_internal_tor">Motor de Tor activo</string>
|
||||
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene configurada una dirección de Lightning para recibir sats</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">Error al cargar voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Error al cargar el mensaje de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 aún no es compatible con mensajes de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidad</string>
|
||||
<string name="uptime">%1$d% de disponibilidad</string>
|
||||
<string name="use_internal_tor">Motor de Tor activo</string>
|
||||
<string name="use_internal_tor_explainer">Usar la versión interna u Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">El usuario no tiene una configuración de dirección Lightning para recibir sats</string>
|
||||
|
||||
@@ -2909,7 +2909,7 @@
|
||||
<string name="upload_error_voice_message_exception">آپلود صوتی ناموفق بود: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیامهای صوتی پشتیبانی نمیشود</string>
|
||||
<string name="uptime">%1$d%% زمان فعالیت</string>
|
||||
<string name="uptime">%1$d% زمان فعالیت</string>
|
||||
<string name="use_internal_tor">موتور فعال تور</string>
|
||||
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">آپلود صوتی ناموفق بود: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">آپلود پیام صوتی ناموفق بود</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 هنوز برای پیامهای صوتی پشتیبانی نمیشود</string>
|
||||
<string name="uptime">%1$d%% زمان فعالیت</string>
|
||||
<string name="uptime">%1$d% زمان فعالیت</string>
|
||||
<string name="use_internal_tor">موتور فعال تور</string>
|
||||
<string name="use_internal_tor_explainer">استفاده از نسخه درونی یا Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
|
||||
|
||||
@@ -2877,7 +2877,7 @@
|
||||
<string name="upload_error_voice_message_exception">Ääniviesti epäonnistui: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Ääniviesti epäonnistui ladata</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 ei tue ääniviestiä vielä</string>
|
||||
<string name="uptime">%1$d%% käytettävyys</string>
|
||||
<string name="uptime">%1$d% käytettävyys</string>
|
||||
<string name="use_internal_tor">Aktiivinen Tor-moottori</string>
|
||||
<string name="use_internal_tor_explainer">Käytä sisäistä versiota tai Orbotia</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Käyttäjällä ei ole asetettuna Lightning-osoitetta satsien vastaanottamiseksi</string>
|
||||
|
||||
@@ -2802,7 +2802,7 @@
|
||||
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
|
||||
<string name="uptime">%1$d%% de disponibilité</string>
|
||||
<string name="uptime">%1$d% de disponibilité</string>
|
||||
<string name="use_internal_tor">Activer le moteur Tor</string>
|
||||
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
|
||||
|
||||
@@ -3092,7 +3092,7 @@
|
||||
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
|
||||
<string name="uptime">%1$d%% de disponibilité</string>
|
||||
<string name="uptime">%1$d% de disponibilité</string>
|
||||
<string name="use_internal_tor">Activer le moteur Tor</string>
|
||||
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">Échec de l'envoi de la voix : %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Échec de l'envoi du message vocal</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 n'est pas encore supporté pour les messages vocaux</string>
|
||||
<string name="uptime">%1$d%% de disponibilité</string>
|
||||
<string name="uptime">%1$d% de disponibilité</string>
|
||||
<string name="use_internal_tor">Activer le moteur Tor</string>
|
||||
<string name="use_internal_tor_explainer">Utiliser la version interne ou Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utilisateur n'a pas configuré d'adresse Lightning pour recevoir des sats</string>
|
||||
|
||||
@@ -654,6 +654,7 @@
|
||||
<string name="mark_all_new_as_read">सब नये को पढ लिया चिह्नित करें</string>
|
||||
<string name="mark_all_as_read">सब को पढ लिया चिह्नित करें</string>
|
||||
<string name="account_backup_headline">आपकी गुप्त कुंचिका आपका लेखा है</string>
|
||||
<string name="account_backup_intro">कोई भी जिसके पास यह है आप के रूप में पत्र प्रकाशन कर सकता है। यदि आप इसे खो देते हैं तो कोई भी इसे पुनःप्राप्त नहीं कर सकता। अमेथिस्ट भी नहीं।</string>
|
||||
<string name="account_backup_tap_to_reveal">दबाएँ देखने के लिए</string>
|
||||
<string name="account_backup_write_down_hint">इसे इस क्रम मे लिख लें। बडे अथवा छोटे अक्षर दोनों कार्य करेंगे।</string>
|
||||
<string name="account_backup_qr_code">क्यूआर अक्षरराशि</string>
|
||||
@@ -665,7 +666,11 @@
|
||||
<string name="account_backup_encrypted_password_mismatch">पारणशब्दों में मेल नहीं</string>
|
||||
<string name="account_backup_encrypt">रहस्यीकरण कुंचिका</string>
|
||||
<string name="account_backup_encrypting">रहस्यीकरणमध्य…</string>
|
||||
<string name="account_backup_encrypted_saved_hint">पारणशब्द प्रबन्धक अथवा मेघ टीकाओं में सुरक्षित रखना चाहिए। इसे पारणशब्द बिना खोला नहीं जा सकता। विस्मृत पारणशब्द को पुनःप्राप्त नहीं किया जा सकता।</string>
|
||||
<string name="account_backup_encrypt_again">एक पृथक पारणशब्द का उपयोग करें</string>
|
||||
<string name="account_backup_tip_storage">पत्र को कहीं रखें जहाँ केवल आप इसे खोज सकते हैं। अथवा कुंचिका को पारणशब्द प्रबन्धक में रखें।</string>
|
||||
<string name="account_backup_tip_trust">कभी भी आपकी कुंचिका को जालस्थान अथवा क्रमकों में ना चिपकाएँ जिनपर आप विश्वास नहीं करते।</string>
|
||||
<string name="account_backup_tip_developers">अमेथिस्ट के विकासक आपकी कुंचिका के लिए कभी नहीं पूछेंगे।</string>
|
||||
<string name="backup_keys_nudge_title">अपने कुंचिकाओं को सुरक्षित रखें</string>
|
||||
<string name="backup_keys_nudge_body">आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय।</string>
|
||||
<string name="backup_keys_nudge_backup_now">अभी इसी समय सुरक्षित अनुकृति बनाएँ</string>
|
||||
@@ -1729,6 +1734,14 @@
|
||||
<string name="relay_group_field_name">नाम</string>
|
||||
<string name="relay_group_field_about">विवरण</string>
|
||||
<string name="relay_group_field_picture">समूह चित्र</string>
|
||||
<string name="relay_group_field_banner">ध्वज चित्र जालपता</string>
|
||||
<string name="relay_group_migration_moved_title">यह समूह सम्भाव्यतः विस्थापित</string>
|
||||
<string name="relay_group_migration_fork_title">यह समूह सम्भाव्यतः पथभेदयुक्त</string>
|
||||
<string name="relay_group_migration_by_admins">इसके प्रशासक इसे अब %1$s पर अनुसूचित करते हैं</string>
|
||||
<string name="relay_group_migration_by_friends">लोग जिनका आप अनुगमन करते हैं अब इसे %1$s पर अनुसूचित करते हैं</string>
|
||||
<string name="relay_group_migration_open">वहाँ खोलें</string>
|
||||
<string name="relay_group_migration_move">स्थानान्तरण</string>
|
||||
<string name="relay_group_migration_dismiss">हटाएँ</string>
|
||||
<string name="relay_group_add_photo">चित्र जोडें</string>
|
||||
<string name="relay_group_change_photo">चित्र परिवर्तन</string>
|
||||
<string name="relay_group_section_discovery">आविष्करण</string>
|
||||
@@ -2465,6 +2478,8 @@
|
||||
<string name="select_list_to_filter">सूचनावली छानने के लिए सूची चुनें</string>
|
||||
<string name="add_dvm_to_favorites">सूचनावली कलनविधि जोडें इष्टसूची में</string>
|
||||
<string name="remove_dvm_from_favorites">इष्टसूची में से हटाएँ</string>
|
||||
<string name="add_follow_set_to_favorites">अनुगमन समुच्चय को इष्ट सूचनावलियों में जोडें</string>
|
||||
<string name="remove_follow_set_from_favorites">अनुगमन समुच्चय को इष्ट सूचनावलियों में से हटाएँ</string>
|
||||
<string name="favorite_dvms_explainer">सूचनावली कलनविधियाँ जिन्हें आप ताराचिह्नित किए यहाँ दिखेंगे तथा मुख्य सूचनावली पर छलनी खण्डों के रूप में दिखेंगे।</string>
|
||||
<string name="favorite_dvms_empty_headline">आपके इष्ट कलनविधियाँ अटकाएँ</string>
|
||||
<string name="favorite_dvms_empty_step1">नीचे "%1$s" दबाएँ कलनविधियों का वीक्षण करने के लिए।</string>
|
||||
@@ -2685,6 +2700,20 @@
|
||||
<string name="nickname_private">केवल आपको दिखाई देगा</string>
|
||||
<string name="cast_to_device_dialog_title">निक्षेपण इस पर…</string>
|
||||
<string name="cast_searching_for_devices">आपके वैफै॰ पर यन्त्रों का खोज चालू…</string>
|
||||
<string name="cast_local_network_permission_title">अनुमति आवश्यक</string>
|
||||
<string name="cast_local_network_permission_message">अमेथिस्ट को स्थानीय जाल अभिगमन अनुमति चाहिए आपके वैफै पर प्रसार यन्त्रों को खोजने के लिए। कृपया क्रमक स्थापना विकल्पों में सक्षम करें।</string>
|
||||
<string name="cast_local_network_permission_open_settings">स्थापना विकल्प खोलें</string>
|
||||
<string name="cast_generic_receiver">प्रसार यन्त्र</string>
|
||||
<string name="cast_error_unsupported_media">%1$s इस चलचित्र प्रारूप को चला नहीं सकते</string>
|
||||
<string name="cast_error_playback_failed">%1$s इस चलचित्र को चलाने में असफल</string>
|
||||
<string name="cast_error_receiver_not_responding">%1$s से प्रतिवचन रुक गए। यन्त्र पुनःआरम्भ करने से सामन्यतः यह ठीक हो जाता है।</string>
|
||||
<string name="cast_error_connect_failed">%1$s से संयोजन असफल। यन्त्र पुनःआरम्भ करने से सामन्यतः यह ठीक हो जाता है।</string>
|
||||
<string name="cast_error_device_offline">%1$s जाल संयोजन रहित हो गया</string>
|
||||
<string name="cast_error_load_failed">%1$s पर इस चलचित्र का आवहन असफल</string>
|
||||
<string name="cast_error_play_services_unavailable">प्रसारण के लिए गूगिलप्लेय सेवाएँ आवश्यक। जो इस यन्त्र पर उपलब्ध नहीं</string>
|
||||
<string name="cast_error_unsupported_media_detail">%1$s इस चलचित्र प्रारूप को चला नहीं सकते। %2$s</string>
|
||||
<string name="cast_error_playback_failed_detail">%1$s इस चलचित्र को चलाने में असफल। %2$s</string>
|
||||
<string name="cast_error_receiver_not_responding_detail">%1$s ने इस चलचित्र को चलाने का आरम्भ ही नहीं किया। %2$s। सम्भाव्यतः यह उस प्रारूप का अवलम्बन नहीं करता। अथवा यन्त्र को पुनःआरम्भ करना सहायक हो सकता है।</string>
|
||||
<string name="hls_pick_video_primary">दृश्याभिलेख चुनें</string>
|
||||
<string name="hls_pick_video_helper">आपका दृश्याभिलेख अनेक सुलझावों में परिवर्तित किया जाएगा जिससे दर्शकों को स्निग्ध चालन प्राप्त होगा किसी भी संयोजन पर।</string>
|
||||
<string name="hls_change_video">परिवर्तन</string>
|
||||
@@ -2796,8 +2825,13 @@
|
||||
<string name="relay_management_no_moderation_events">नियमनार्थ कोई घटनाएँ नहीं</string>
|
||||
<string name="relay_management_banned_events">प्रतिबन्धित घटनाएँ</string>
|
||||
<string name="relay_management_no_banned_events">कोई प्रतिबन्धित घटनाएँ नहीं</string>
|
||||
<string name="relay_management_allowed_events">अनुमत प्रकार</string>
|
||||
<string name="relay_management_no_allowed_events">कोई अनुमत प्रकार नहीं</string>
|
||||
<string name="relay_management_allow_event">प्रकार को अनुमति</string>
|
||||
<string name="relay_management_allowed_kinds">अनुमत प्रकार</string>
|
||||
<string name="relay_management_no_allowed_kinds">कोई अनुमत प्रकार नहीं</string>
|
||||
<string name="relay_management_disallowed_kinds">अनुमति रहित प्रकार</string>
|
||||
<string name="relay_management_no_disallowed_kinds">कोई अनुमत रहित प्रकार नहीं</string>
|
||||
<string name="relay_management_blocked_ips">अवरोधित अंकीय जालपताएँ</string>
|
||||
<string name="relay_management_no_blocked_ips">कोई अवरोधित अंकीय जालपताएँ नहीं</string>
|
||||
<string name="relay_management_add">जोडें</string>
|
||||
@@ -2830,6 +2864,9 @@
|
||||
<string name="relay_members_join_sent">जुडने का अनुरोध भेजा गया</string>
|
||||
<string name="relay_members_leave_sent">चले जाने का अनुरोध भेजा गया</string>
|
||||
<string name="relay_members_you_are_member">आप एक सदस्य हैं</string>
|
||||
<string name="relay_members_invite_code">आमन्त्रण अक्षरराशि</string>
|
||||
<string name="relay_members_invite_code_hint">यह पुनःप्रसारक इसके परिचालक से आमन्त्रण अक्षरराशि प्राप्त सदस्यों को स्वीकारता है</string>
|
||||
<string name="relay_members_unverifiable">यह पुनःप्रसारक अपनी परिचायक कुंचिका का प्रकाशन नहीं करता। निप॰ग्यारह स्वयम। इसलिए इसकी सदस्यसूची का सत्यापन असाध्य</string>
|
||||
<string name="relay_membership_list">पुनःप्रसारक सदस्यता सूची</string>
|
||||
<string name="relay_member_added">सदस्य जोडा गया पुनःप्रसारक के साथ</string>
|
||||
<string name="relay_members_added">%1$d सदस्य जोडे गये पुनःप्रसारक के साथ</string>
|
||||
@@ -4569,6 +4606,7 @@
|
||||
<string name="podcast_trailer_upload_hint">लघु ध्वनि अथवा दृश्य पूर्वीक्षण</string>
|
||||
<string name="podcast_value_error_title">मूल्य के लिए मूल्य अपक्रम</string>
|
||||
<string name="podcast_value_keysend_requires_nwc">एक नोस्टर धनकोष संयोजन जोडें कुंचिकाप्रेषण (जालबिन्दु) प्राप्तकर्ताओं को भेजने के लिए।</string>
|
||||
<string name="podcast_value_keysend_not_supported">आपका धनकोष कुंचिकाप्रेषण का अवलम्बन नहीं करता। इसलिए जालबिन्दु प्राप्तकर्ताओं को छोड दिया गया।</string>
|
||||
<string name="podcast_value_no_recipients">इस पुटप्रसार का कोई भुगतान सम्भाव्य मूल्य प्राप्तकर्ता नहीं।</string>
|
||||
<string name="podcast_value_stream_requires_wallet">एक नोस्टर धनकोष संयोजन अथवा ऋणांकन धनकोष जोडें सुनते समय साट्स प्रवाह चलाने के लिए।</string>
|
||||
<string name="podcast_value_user_no_lnaddress">इस प्रयोक्ता का कोई लैटनिंग॰ पता नहीं</string>
|
||||
@@ -5147,7 +5185,7 @@
|
||||
<string name="upload_error_voice_message_exception">ध्वनि आरोहण असफल : %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">ध्वनि सन्देश आरोहण असफल</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">निप॰९५ का आलम्बन अभी नहीं है ध्वनि सन्देशों के लिए</string>
|
||||
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
|
||||
<string name="uptime">%1$d% समय निरन्तर उपलब्ध</string>
|
||||
<string name="use_internal_tor">सक्रिय टोर उपकरण</string>
|
||||
<string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
|
||||
@@ -5200,6 +5238,7 @@
|
||||
</plurals>
|
||||
<string name="wallet_transactions_load_failed">व्यापारों का आवहन असफल</string>
|
||||
<string name="wallet_transactions_load_more_failed">और अधिक व्यापारों का आवहन असफल</string>
|
||||
<string name="wallet_transactions_not_supported">आपका धनकोष व्यापार इतिहास उपलब्ध नहीं करता</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_explainer">चेतावनी सन्देश दिखाता है जब पत्र अथवा परिचय पर वृत्तान्त हों आपके द्वारा अनुचरित लेखाओं से</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_title">वृत्तान्त प्राप्त होने पर चेतावनी दें</string>
|
||||
<string name="web_bookmark_add_title">जाल स्मर्त्तव्यचिह्न जोडें</string>
|
||||
@@ -5242,6 +5281,7 @@
|
||||
<string name="cordn_badge_coordinator">समायोजक समूह</string>
|
||||
<string name="cordn_badge_coordinator_desc">समायोजक द्वारा भेजा गया। पुनःप्रसारकों द्वारा नहीं</string>
|
||||
<string name="cordn_exposure_title">यह समायोजक क्या देख सकता है</string>
|
||||
<string name="cordn_exposure_subtitle">समायोजक एक सेवासंगणक है जो सभी सन्देशों का वहन करता है इस समूह में क्रमबद्धतः। पुनःप्रसारकों से पृथक व्यवसाय है। सामान्य चर्चा जैसे बर्ताव करने से पूर्व यह जानना लाभदायक।</string>
|
||||
<string name="cordn_exposure_dimension_content">सन्देश विषयवस्तु</string>
|
||||
<string name="cordn_exposure_dimension_membership">समूह में कौन हैं</string>
|
||||
<string name="cordn_exposure_dimension_messaging">कौन क्या भेजता है</string>
|
||||
@@ -5250,9 +5290,21 @@
|
||||
<string name="cordn_exposure_level_identified">आपकी वास्तविक लेखा से सम्बद्ध</string>
|
||||
<string name="cordn_exposure_details">विवरण</string>
|
||||
<string name="cordn_exposure_coordinator">समायोजक %1$s</string>
|
||||
<string name="cordn_exposure_differs">यह मार्मोट समूह के समान असुरक्षा नहीं। जहाँ कोई एक परिचालक सम्पूर्ण संवाद को नहीं रखता।</string>
|
||||
<string name="cordn_note_membership">जुडने से वास्तविक लेखा नामित होते हैं दोनों अन्तों पर। इसलिए समायोजक को पता है इस समूह में कौन हैं।</string>
|
||||
<string name="cordn_note_linked">एक अस्थायी कुंचिका भेजता है तथा प्राप्त करता है प्रत्येक समूह के लिए जो आप यहाँ रखते हैं। इसलिए समायोजक को पता चल जाता है कि वे सब आप हैं।</string>
|
||||
<string name="cordn_note_history">एक परिचालक सम्पूर्ण क्रमबद्ध इतिहास रखता है अपने द्वारा वहित प्रत्येक समूह का।</string>
|
||||
<string name="cordn_note_publication">आपके द्वारा प्रकाशित कुंचिका पोटली एक हस्ताक्षरित अभिलेख है कि यह लेखा कोर्डन का उपयोग करता है। तथा समायोजक इसे किसी को भी दे सकता है।</string>
|
||||
<string name="cordn_note_sizes">सन्देशों को समाकार नहीं किया जाता। इसलिए समायोजक देख सकता है कि प्रत्येक कितना दीर्घ है।</string>
|
||||
<string name="cordn_note_encryption_bug">यह ग्राहक समायोजक तक रहस्यीकरण नहीं करता। यह एक दोष है। कृपया वृत्तान्त भेजें।</string>
|
||||
<string name="cordn_health_unknown">सम्पर्क अब तक किया नहीं</string>
|
||||
<string name="cordn_health_ok">प्रतिवचनमध्य</string>
|
||||
<string name="cordn_health_down">कोई प्रतिवचन नहीं</string>
|
||||
<plurals name="cordn_health_failures">
|
||||
<item quantity="one">%1$d असफल प्रयास निरन्तर</item>
|
||||
<item quantity="other">%1$d असफल प्रयास निरन्तर</item>
|
||||
</plurals>
|
||||
<string name="cordn_link_explainer">एक cordn1… योजक चिपकाएँ जिसे किसी ने आपके साथ बाँटा यह जानने के लिए कि किस समायोजक उस समूह का वहन करता है। तथा वह परिचालक आपके विषय में क्या जान सकेगा यदि आप जुडते हैं।</string>
|
||||
<string name="cordn_link_field">cordn1…</string>
|
||||
<string name="cordn_link_inspect">निरीक्षण</string>
|
||||
<string name="cordn_link_paste">चिपकाएँ</string>
|
||||
@@ -5261,6 +5313,8 @@
|
||||
<string name="cordn_link_group_id">समूह परिचायक</string>
|
||||
<string name="cordn_link_coordinator">समायोजक</string>
|
||||
<string name="cordn_link_relays">अभिगम्य इनके द्वारा</string>
|
||||
<string name="cordn_link_no_coordinator">यह योजक किसी समायोजक को नामित नहीं करता। इसलिए इसका अनुगमन अपने आप नहीं किया जा सकता। जिसने भी इसे भेजा उनसे पूछें कि किस समायोजक समूह का वहन करता है।</string>
|
||||
<string name="cordn_link_not_joinable">योजक के निरीक्षण से किसी से जुडते नहीं। तथा एक को रखने से आप सदस्य नहीं बनते।</string>
|
||||
<!-- cordn entries in the settings catalog and the Messages chat-type
|
||||
list. Here rather than in amethyst/res because both screens read
|
||||
them through Res. -->
|
||||
@@ -5273,6 +5327,7 @@
|
||||
<string name="cordn_backup_title">कोर्डन सुरक्षित अनुकृति</string>
|
||||
<string name="cordn_backup_search_keywords">कोर्डन सुरक्षित अनुकृति पुनःस्थापन निर्यात आयात पारणवाक्याम्श समूह पुनःप्राप्ति</string>
|
||||
<string name="chat_type_cordn_title">कोर्डन समूह</string>
|
||||
<string name="chat_type_cordn_desc">आरम्भ से अन्त तक रहस्यीकृत समूह। तथा आपके द्वारा चयनित समायोजक द्वारा क्रमबद्ध। पुनःप्रसारकों द्वारा नहीं।</string>
|
||||
<!-- cordn strings on screens main migrated to Res: the shared inbox row
|
||||
and the new-conversation picker. -->
|
||||
<string name="cordn_group_untitled">समूह %1$s</string>
|
||||
@@ -5284,15 +5339,22 @@
|
||||
<string name="cordn_preview_video">चलचित्र</string>
|
||||
<string name="cordn_preview_file">अभिलेख</string>
|
||||
<string name="new_conversation_cordn_title">कोर्डन समूह</string>
|
||||
<string name="new_conversation_cordn_tagline">रहस्यीकृत समूह चर्चा क्रमबद्ध आपके द्वारा चयनित समायोजक द्वारा।</string>
|
||||
<string name="new_conversation_cordn_chip">समायोजित</string>
|
||||
<string name="new_conversation_cordn_best">सर्वोत्तम उन दलों के लिए जो वार्तालाप का एक विश्वसनीय अनुक्रमण चाहते हैं।</string>
|
||||
<string name="new_conversation_cordn_cta">कोर्डन समूह बनाएँ</string>
|
||||
<string name="new_conversation_cordn_pro_1">आरम्भ से अन्त तक रहस्यीकृत एमएलएस॰ के साथ। समायोजक सन्देश कभी पढ नहीं सकता।</string>
|
||||
<string name="new_conversation_cordn_pro_2">एक स्वीकृत अनुक्रमण सभी के लिए। जिससे इतिहास को अस्तव्यस्त नहीं किया जा सकता एक भ्रष्ट घडी से।</string>
|
||||
<string name="new_conversation_cordn_con_1">समायोजक जान लेता है समूह में कौन हैं तथा आप कब बात करते है। यद्यपि वह पढ नहीं सकता कि आप ने क्या कहा।</string>
|
||||
<string name="cordn_invitations_entry">क्या आप एक कोर्डन समूह में आमन्त्रित हैं।</string>
|
||||
<string name="cordn_invitations_entry_action">आमन्त्रण पुनरीक्षण</string>
|
||||
<!-- cordn device migration (spec/applications/multi-device.md §9/§11), as a
|
||||
one-shot handoff rather than continuous sync. The exposure strings say
|
||||
what leaves the device; the fork strings say why the old phone stops. -->
|
||||
<string name="cordn_hub_title">कोर्डन</string>
|
||||
<string name="cordn_hub_explainer">समूह प्रदत्त एक समायोजक द्वारा जिसका चयन आप करते हैं। पुनःप्रसारकों द्वारा नहीं।</string>
|
||||
<string name="cordn_hub_link">समूह योजक का निरीक्षण</string>
|
||||
<string name="cordn_hub_link_desc">देखें कि एक cordn1… योजक कहाँ ले जाएगा कुछ करने से पूर्व</string>
|
||||
<string name="cordn_migrate_section_send">इस संचारयन्त्र से विस्थापन</string>
|
||||
<string name="cordn_migrate_section_receive">अन्य संचारयन्त्र से प्राप्त करें</string>
|
||||
<string name="cordn_keypackages_section">प्रकाशित कुंचिका पोटलियाँ</string>
|
||||
@@ -5312,22 +5374,32 @@
|
||||
<string name="cordn_hub_backup">सुरक्षित अनुकृति तथा पुनःस्थापन</string>
|
||||
<string name="cordn_hub_backup_desc">पारणवाक्याम्श द्वारा रहस्यीकृत एक अभिलेख जो आप रखते हैं</string>
|
||||
<string name="cordn_hub_migrate">अन्य संचारयन्त्र तक विस्थापन</string>
|
||||
<string name="cordn_hub_migrate_desc">आपके समूहों का हस्तान्तरण एक यन्त्र तक जिसपर आप विस्थापन कर रहे हैं</string>
|
||||
<string name="cordn_migrate_title">अन्य संचारयन्त्र तक विस्थापन</string>
|
||||
<string name="cordn_migrate_explainer">आपके कोर्डन समूह इस यन्त्र पर रहते हैं। स्थानान्तरण के लिए एक अक्षरराशि चाहिए। यह यन्त्र उसका प्रकाशन करता है। नया यन्त्र उसे पढता है। तत्पश्चात यह यन्त्र रुक जाता है।</string>
|
||||
<string name="cordn_migrate_sign_in_first">सम लेखा से नए संचारयन्त्र में हस्ताक्षर प्रवेश पहले करें। यह आपके समूहों को विस्थापित करता है। कभी भी आपकी कुंचिका को नहीं।</string>
|
||||
<string name="cordn_migrate_export">हस्तान्तरण आरम्भ करें</string>
|
||||
<string name="cordn_migrate_exporting">प्रकाशनमध्य…</string>
|
||||
<string name="cordn_migrate_scan_this">नए संचारयन्त्र में इसे परखें</string>
|
||||
<string name="cordn_migrate_code_note">अक्षरराशि केवल एक संकेतक है। कोई इसका चित्रग्रहण करें कुछ भी सीखेगा नहीं। यह जिसका संकेत करता है वह रहस्यीकृत है। केवल आप के द्वारा पठनशक्य।</string>
|
||||
<string name="cordn_migrate_handed_off">इस यन्त्र ने अपने समूहों का हस्तान्तरण कर दिया</string>
|
||||
<string name="cordn_migrate_handed_off_desc">इसने भेजना तथा प्राप्त करना रोक दिया। क्योंकि दो संचारयन्त्र एक समूह में से भजने से यह सब के लिए टूट जाएगा। आपके समूह अभी भी इस यन्त्र पर हैं। कुछ भी हटाया नहीं गया।</string>
|
||||
<string name="cordn_migrate_cancel">इस यन्त्र को आप पुनः ले जाएँ</string>
|
||||
<string name="cordn_migrate_cancel_desc">इसका उपयोग करें यदि विस्थापान सम्पन्न नहीं हुआ। तब ही उपयोग करें यदि नया संचारयन्त्र भेजना आरम्भ नहीं किया।</string>
|
||||
<string name="cordn_migrate_receive">अन्य संचारयन्त्र से प्राप्त करें</string>
|
||||
<string name="cordn_migrate_scan">अक्षरराशि परखें</string>
|
||||
<string name="cordn_migrate_paste">अथवा चिपकाएँ</string>
|
||||
<string name="cordn_migrate_import">समूहों को यहाँ ले आएँ</string>
|
||||
<string name="cordn_migrate_importing">लाया जा रहा है…</string>
|
||||
<string name="cordn_migrate_replaces_warning">यह इस संचारयन्त्र पर पहले से उपलब्ध कोर्डन समूहों का प्रतिस्थापन करता है। उनका विलय नहीं किया जा सकता।</string>
|
||||
<plurals name="cordn_migrate_done">
|
||||
<item quantity="one">%1$d समूह स्थानान्तरित</item>
|
||||
<item quantity="other">%1$d समूह स्थानान्तरित</item>
|
||||
</plurals>
|
||||
<string name="cordn_migrate_no_groups">इस यन्त्र पर कोई कोर्डन समूह नहीं विस्थापन के लिए।</string>
|
||||
<string name="cordn_migrate_no_server">श्रव्यदृश्याभिलेख सेवासंगणक को पहले स्थापित करें। रहस्यीकृत अभिलेखों को रखने के लिए एक स्थान चाहिए अन्य संचारयन्त्र द्वारा लाए जाने तक।</string>
|
||||
<string name="cordn_migrate_exposure_title">इस यन्त्र से क्या जाता है</string>
|
||||
<string name="cordn_migrate_exposure_body">आपकी समूह स्थिति रहस्यीकृत है तथा आपके श्रव्यदृश्याभिलेख सेवासंगणक पर आरोहित जिससे अन्य संचारयन्त्र उसको प्राप्त कर सके। वह उस सेवासंगणक के लिए पठन साध्य नहीं। वह केवल आकार तथा समय देख सकता है। तत्पश्चात अभिलेखों को मिटा दें यदि आप कुछ पीछे नहीं छोडना चाहते।</string>
|
||||
<string name="sno_object_title">त्रिमा वस्तु</string>
|
||||
<string name="sno_object_details">%1$d अस्र। %2$d मुख</string>
|
||||
<string name="sno_object_unreadable">इस त्रिमा वस्तु का पठन असफल। नियम %1$s</string>
|
||||
@@ -5409,7 +5481,9 @@
|
||||
<string name="browser_pill_site_settings_none">कुछ भी अनुमत नहीं अब तक</string>
|
||||
<string name="browser_pill_access">इसके लिए क्या अभिगम्य है</string>
|
||||
<string name="browser_pill_access_desc">आपके कुंचिकाएँ कभी भी अमेथिस्ट से बाहर नहीं जाते</string>
|
||||
<string name="browser_pill_access_keys_desc">प्रत्येक हस्ताक्षरण प्रकाशन आरोहण अथवा भुगतान आपकी अनुमति द्वारा जाते हैं</string>
|
||||
<string name="browser_pill_access_none">कोई विशेष अभिगमन अनुमति नहीं</string>
|
||||
<string name="browser_pill_access_none_desc">यह पृथककृत पर्यावरण में चलता है। तथा आपके लेखा पर कार्य नहीं कर सकता</string>
|
||||
<string name="browser_pill_access_manage">अनुमतियाँ प्रबन्धन</string>
|
||||
<string name="browser_pill_access_nsite">नोस्टरस्थान। पृथककृत पर्यावरण</string>
|
||||
<string name="browser_pill_access_napplet">नोस्टरसंलग्नक्रमक। पृथककृत पर्यावरण</string>
|
||||
@@ -5448,19 +5522,24 @@
|
||||
<string name="browser_pill_perm_camera_desc">देखें आपका चित्रग्राहक क्या देख रहा है</string>
|
||||
<string name="browser_pill_perm_microphone_desc">सुनें आपका ध्वनिग्राहक क्या सुन रहा है</string>
|
||||
<string name="browser_pill_perm_location_desc">जानें आप कहाँ हैं स्थूलतः</string>
|
||||
<string name="browser_pill_perm_tor_note">आह्वान आपकी अंकीय जालपता को दिखा सकते हैं टोर पर भी।</string>
|
||||
<string name="browser_pill_perm_allow">अभ्यागमन कालान्तर अनुमत</string>
|
||||
<string name="browser_pill_perm_once">केवल इस बार</string>
|
||||
<string name="browser_pill_perm_deny">अनुमति ना दें</string>
|
||||
<string name="browser_pill_dialog_says">%1$s कहता है</string>
|
||||
<string name="browser_pill_dialog_generic">यह पृष्ठ कहता है</string>
|
||||
<string name="browser_pill_dialog_block">इस पृष्ठ को और अधिक वार्ताकोष्ठक दिखाने ना दें</string>
|
||||
<string name="browser_pill_dialog_answer">आपका उत्तर</string>
|
||||
<string name="browser_pill_dialog_leave_title">क्या जालस्थान छोड दें।</string>
|
||||
<string name="browser_pill_dialog_leave_message">परिवर्तन जो आपने किए सम्भाव्यतः अभिलेखित नहीं।</string>
|
||||
<string name="browser_pill_dialog_leave">निर्गमन</string>
|
||||
<string name="browser_pill_ok">ठीक</string>
|
||||
<string name="browser_pill_cancel">निरस्त</string>
|
||||
<string name="browser_pill_info_connection">संयोजन</string>
|
||||
<string name="browser_pill_info_https">संयोजन रहस्यीकृत</string>
|
||||
<string name="browser_pill_info_https_desc">जानकारी जो आप भेजते हैं मार्गमध्य पठन असाध्य।</string>
|
||||
<string name="browser_pill_info_http">संयोजन अरहस्यीकृत</string>
|
||||
<string name="browser_pill_info_http_desc">पारणशब्द अथवा भुगतान विवरणों का प्रविष्ट ना करें।</string>
|
||||
<string name="browser_pill_info_tor">टोर पर बहुरहस्यावृत संचार द्वारा</string>
|
||||
<string name="browser_pill_info_open">खुला जाल</string>
|
||||
<string name="browser_pill_info_certificate">प्रमाणपत्र</string>
|
||||
@@ -5802,17 +5881,27 @@
|
||||
<string name="cordn_action_reply">उत्तर</string>
|
||||
<string name="cordn_action_unpin">टाँका हटाएँ</string>
|
||||
<string name="cordn_admin_action_failed">वह परिवर्तन असफल।</string>
|
||||
<string name="cordn_admin_coordinator_silent">समायोजक ने उत्तर नहीं दिया। इसलिए कुछ भी परिवर्तित नहीं। कुछ क्षण पश्चात पुनःप्रयास करें।</string>
|
||||
<string name="cordn_admin_no_key_package">%1$s ने कुंचिका पेटली प्रकाशित नहीं की अब तक इस समायोजक के लिए। इसलिए वे जोडे नहीं जा सकते अब तक। उनसे पूछें पहले कोर्डन को खोलने के लिए इस समायोजक पर।</string>
|
||||
<string name="cordn_admin_no_self_remove">आप अपने को हटा नहीं सकते एक कोर्डन समूह से।</string>
|
||||
<string name="cordn_admin_no_welcome">%1$s को समूह में आने की विधि नहीं दे सके। इसलिए जोडने का प्रयास परित्यक्त।</string>
|
||||
<string name="cordn_admin_not_a_member">%1$s इस समुह में नहीं है।</string>
|
||||
<string name="cordn_admin_someone">वह व्यक्ति</string>
|
||||
<string name="cordn_admin_wrong_key_package">इस समायोजक ने किसी दूसरे की कुंचिका पोटली भेजी। इसलिए %1$s को नहीं जोडा गया। कुछ भी परिवर्तित नहीं।</string>
|
||||
<string name="cordn_backup_contents_body">आपके रहस्यीकरण कुंचिकाएँ आपके कुंचिका पोटलियाँ तथा सभी सन्देश उन समूहों में। जो भी इसे खोलता है वार्तालापों को पढ सकता है। पारणवाक्याम्श एकमात्र वस्तु है जो इसे सुरक्षित रखता है अमेथिस्ट से निर्गमन पश्चात।</string>
|
||||
<string name="cordn_backup_contents_title">अभिलेख में क्या है</string>
|
||||
<string name="cordn_backup_explainer">आपका एनसेक॰ कोर्डन समूहों को पुनःप्राप्त नहीं कर सकता। एक कोर्डन समूह रहस्यीकृत स्थिति के रूप में रहता है इस यन्त्र पर तथा एक क्रमबद्ध प्रवाह के रूप में समायोजक पर जो उसे पढ नहीं सकता। यन्त्र खो देते हैं बिना एक सुरक्षित अनुकृति के तो समूह भी गया। उसमें की गयी सभी बातें समेत।</string>
|
||||
<string name="cordn_backup_export">निर्यात</string>
|
||||
<string name="cordn_backup_exported">अनुकृति सुरक्षित।</string>
|
||||
<string name="cordn_backup_failed">वह कार्य असफल था। पारणवाक्याम्श तथा अभिलेख की जाँच करें।</string>
|
||||
<string name="cordn_backup_passphrase">पारणवाक्याम्श</string>
|
||||
<string name="cordn_backup_restore">पुनःस्थापन</string>
|
||||
<string name="cordn_backup_restore_body">पुनःस्थापन से इस यन्त्र पर कोर्डन समूह प्रतिस्थापित किए जाएँगे अभिलेखित समूहों के साथ। यह एक संचारयन्त्र पर एक अन्य से विस्थापित करने के लिए है। समूहों को दो यन्त्रों पर एक साथ उपयोग करने की विधि नहीं।</string>
|
||||
<string name="cordn_backup_restore_confirm_body">सभी कोर्डन समूह जो इस यन्त्र पर हैं हटाए जाएँगे तथा अभिलेख में उपलब्ध से प्रतिस्थापित होंगे।\n\nदूसरे संचारयन्त्र में पुनःस्थापन ना करें जब पहला का भी उपयोग हो रहा है। दोनों में समान समूह कुंचिकाएँ होंगी। तथा जब दोनों एक सन्देश भेजेंगे तब समूह सब के लिए टूट जाएगा। मौनतः तथा पूर्ववत करने की विधि के बिना।</string>
|
||||
<string name="cordn_backup_restore_confirm_title">क्या इस यन्त्र के कोर्डन समूहों का प्रतिस्थापन करें।</string>
|
||||
<string name="cordn_backup_restore_title">पुनःस्थापन</string>
|
||||
<string name="cordn_backup_restored">पुनःस्थापित।</string>
|
||||
<string name="cordn_chat_empty_description">जो भी यहाँ भेजा जाता है रहस्यीकृत होता है समूह के लिए। समायोजक इसका पुनःप्रसारण करता है उसे पढने की क्षमता के बिना।</string>
|
||||
<string name="cordn_chat_empty_title">कोई सन्देश नहीं अब तक</string>
|
||||
<string name="cordn_chat_unread_divider">नए सन्देश</string>
|
||||
<string name="cordn_chat_yesterday">एक दिन पूर्व</string>
|
||||
@@ -5822,19 +5911,32 @@
|
||||
<string name="cordn_coordinators_copy_key">समायोजक के एनपुब॰ की अनुकृति</string>
|
||||
<string name="cordn_coordinators_discover_action">समायोजकों के लिए ढूँढें</string>
|
||||
<string name="cordn_coordinators_discover_add">जोडें</string>
|
||||
<string name="cordn_coordinators_discover_explainer">पढता है घोषणाएँ जिनका वहन आपके पुनःप्रसारक पहले से करते हैं। किसी भी समायोजक से सम्पर्क नहीं। इसलिए उनमें से किसी को भी पता नहीं चलता कि आपने देखा।</string>
|
||||
<string name="cordn_coordinators_discover_failed">घषणाओं को पढने में असफल</string>
|
||||
<string name="cordn_coordinators_discover_none">आपके पुनःप्रसारकों में कोई भी घषणा नहीं कर रहा।</string>
|
||||
<string name="cordn_coordinators_discover_seen">घोषित %1$s पूर्व</string>
|
||||
<string name="cordn_coordinators_discover_seen_on">पूर्व घोषित %1$s</string>
|
||||
<plurals name="cordn_coordinators_discover_unheard">
|
||||
<item quantity="one">खोज में कुछ भी प्राप्त नहीं। तथा आपके पुनःप्रसारकों में से %1$d ने उत्तर नहीं दिया।</item>
|
||||
<item quantity="other">खोज में कुछ भी प्राप्त नहीं। तथा आपके पुनःप्रसारकों में से %1$d ने उत्तर नहीं दिए।</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_explainer">एक समायोजक एक समूह के सन्देशों को क्रमबद्ध करता है। तथा एकमात्र वह ही उसे कर सकता है। उसको खोने से अनुक्रमण भी खो जाता है। दूसरे के द्वारा प्रथम का प्रतिबिम्ब नहीं होता। इसलिए ये नामित वस्तुएँ हैं जिनका चयन आप करते हैं। प्रतिस्थापनशक्य पुनःप्रसारक नहीं।</string>
|
||||
<plurals name="cordn_coordinators_health_down">
|
||||
<item quantity="one">उत्तर नहीं दे रहा। %1$d आह्वान निरन्तर असफल। इस पर समूहों का समचरणीकरण नहीं हो रहे।</item>
|
||||
<item quantity="other">उत्तर नहीं दे रहा। %1$d आह्वान निरन्तर असफल। इस पर समूहों का समचरणीकरण नहीं हो रहे।</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_health_ok">उत्तर दे रहा है।</string>
|
||||
<string name="cordn_coordinators_health_retrying">एक आह्वान असफल। पुनःप्रयासमध्य।</string>
|
||||
<string name="cordn_coordinators_health_unknown">उससे कुछ पूछा नहीं गया अब तक।</string>
|
||||
<string name="cordn_coordinators_hide_older">उनको छिपाएँ जिन्होंने घोषणा करना रोक दिया</string>
|
||||
<string name="cordn_coordinators_identify">पूछें कि कौन हैं</string>
|
||||
<string name="cordn_coordinators_key">समायोजक कुंचिका</string>
|
||||
<string name="cordn_coordinators_label">उसके लिए एक नाम। (आपका। विककल्पात्मक)</string>
|
||||
<string name="cordn_coordinators_label_note">नाम आपका है तथा इस यन्त्र पर रहेगा। समायोजक नाम का प्रमाणीकरण नहीं कर सकता। इसलिए उसे कभी पूछा नहीं जाता।</string>
|
||||
<string name="cordn_coordinators_more">अधिक क्रियाएँ</string>
|
||||
<string name="cordn_coordinators_none">कोई समायोजक नहीं अब तक।</string>
|
||||
<string name="cordn_coordinators_purge">मिटाएँ</string>
|
||||
<string name="cordn_coordinators_purge_body">हटाने से समूह इस यन्त्र पर रहते हैं। इसलिए समायोजक को पुनःजोडने से समूह भी लौट आते हैं। मिटानेसे समूह मिटते हैं। समूह कुंचिकाएँ तथा पठनस्थान तथा आपके सभी कुंचिका पोटलियाँ सभी चले जाते हैं। तथा नए आमन्त्रण से ही उन समूहों का पुनःप्रवेश कर सकते हैं।\n\nसमायोजक को बताया नहीं जाता। तथा जो उसके पास पहले से था वह उसे रखता है।</string>
|
||||
<string name="cordn_coordinators_purge_title">क्या इस समायोजक को मिटा दें।</string>
|
||||
<string name="cordn_coordinators_relays">%1$s</string>
|
||||
<string name="cordn_coordinators_relays_label">उत्तर सक्रिय</string>
|
||||
@@ -5842,9 +5944,15 @@
|
||||
<string name="cordn_coordinators_remove">हटाएँ</string>
|
||||
<string name="cordn_coordinators_rename">पुनःनामकरण</string>
|
||||
<string name="cordn_coordinators_server">कहता है कि यह है। %1$s</string>
|
||||
<string name="cordn_coordinators_server_claim">समायोजक का नाम तथा संस्करण स्वचयनित सूत्र हैं। उसकी ख्याप्य कुंचिका एकमात्र वस्तु है जो उसका परिचायक है।</string>
|
||||
<string name="cordn_coordinators_server_silent">उत्तर दिया। पर कुछ भी नामित नहीं।</string>
|
||||
<string name="cordn_coordinators_show_all">सब कुछ दिखाएँ %1$d</string>
|
||||
<string name="cordn_coordinators_show_fewer">अल्पतर संख्या दिखाएँ</string>
|
||||
<plurals name="cordn_coordinators_show_older">
|
||||
<item quantity="one">उस %1$d को दिखाएँ जिसने घोषणा करना रोक दिया</item>
|
||||
<item quantity="other">उन %1$d को दिखाएँ जिन्होंने घोषणा करना रोक दिया</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_stale_note">एक मास से इन्होंने घोषणा नहीं की। जो चला गया उस पर समूह बनाना असफल होगा।</string>
|
||||
<string name="cordn_create_action">समूह बनाएँ</string>
|
||||
<plurals name="cordn_create_action_invite">
|
||||
<item quantity="one">बनाएँ तथा %1$d व्यक्ति को आमन्त्रण दें</item>
|
||||
@@ -5852,7 +5960,12 @@
|
||||
</plurals>
|
||||
<string name="cordn_create_add_member">किसी को जोडें</string>
|
||||
<string name="cordn_create_admin">प्रशासक</string>
|
||||
<plurals name="cordn_create_admin_count">
|
||||
<item quantity="one">%1$d व्यक्ति जोड सकता है तथा हटा सकता है</item>
|
||||
<item quantity="other">%1$d व्यक्ति जोड सकते हैं तथा हटा सकते हैं</item>
|
||||
</plurals>
|
||||
<string name="cordn_create_admin_only_me">केवल मैं इस समूह का प्रबन्धक</string>
|
||||
<string name="cordn_create_admin_only_me_on">केवल आप आमन्त्रण दे सकते हैं। तथा सदस्य हटा सकते हैं। अथवा समूह विवरणों को परिवर्तित कर सकते हैं। आगे इसे किसी अन्य को सौंप सकते हैं।</string>
|
||||
<string name="cordn_create_coordinator">समायोजक</string>
|
||||
<string name="cordn_create_coordinator_change">परिवर्तन</string>
|
||||
<string name="cordn_create_coordinator_discover">समायोजक ढूँढें</string>
|
||||
@@ -5860,19 +5973,31 @@
|
||||
<string name="cordn_create_coordinator_new">एक अन्य समायोजक</string>
|
||||
<string name="cordn_create_coordinator_pubkey">समायोजक ख्याप्य कुंचिका। षोडशांक अथवा एनपुब॰</string>
|
||||
<string name="cordn_create_coordinator_relays">पुनःप्रसारक जिन पर यह उत्तर देता है। एक प्रति पंक्ति</string>
|
||||
<plurals name="cordn_create_coverage_all">
|
||||
<item quantity="one">आपके %1$d व्यक्ति तक अभिगम्य</item>
|
||||
<item quantity="other">आपके सभी %1$d व्यक्तियों तक अभिगम्य</item>
|
||||
</plurals>
|
||||
<string name="cordn_create_coverage_partial">%2$d में से %1$d अभिगम्य। अन्य छोड दिए जाएँगे</string>
|
||||
<string name="cordn_create_coverage_unknown">जाँच नहीं की गयी। उसका चयन करने से पूछा जाएगा</string>
|
||||
<string name="cordn_create_description">विवरण (विकल्पात्मक)</string>
|
||||
<string name="cordn_create_egalitarian">सभी जोड सकते हैं तथा हटा सकते हैं</string>
|
||||
<string name="cordn_create_egalitarian_note">समूह में सभी आमन्त्रण दे सकते हैं। तथा सदस्य हटा सकते हैं। तथा समूह विवरणों को परिवर्तित कर सकते हैं। आगे कोई भी प्रशासकों को नियुक्त कर सकते हैं। जिससे उस स्थिति का अन्त हो जाएगा।</string>
|
||||
<string name="cordn_create_explainer">एक कोर्डन समूह आपके द्वारा चयनित समायोजक द्वारा क्रमबद्ध किया जाता है। समूह इस यन्त्र पर बनता है। समायोजक को समूह के अस्तित्व का पता चलता है तब ही जब आप किसी को आमन्त्रित करते हैं अथवा सन्देश भेजते हैं।</string>
|
||||
<string name="cordn_create_failed">समूह बनाने में असफल।</string>
|
||||
<string name="cordn_create_name">समूह नाम</string>
|
||||
<string name="cordn_create_no_coordinator">कोई समायोजक चयनित नहीं</string>
|
||||
<string name="cordn_create_outcome_explainer">प्रत्येक आमन्त्रण समायोजक पर छोडा गया एक स्वागत है। इसलिए वे एक पश्चात एक असफल हो सकते हैं। कोई भी सूचित नहीं। वे देखेंगे जब वे अगली बार अपना क्रमक खोलते हैं।</string>
|
||||
<string name="cordn_create_outcome_failed">समायोजक ने आमन्त्रण अस्वीकार किया</string>
|
||||
<string name="cordn_create_outcome_link">योजक भेजें</string>
|
||||
<string name="cordn_create_outcome_no_welcome">समूह में जोडे गए। पर आमन्त्रण उन तक अभिगम नहीं। समूह पटल से पुनःप्रयास करें</string>
|
||||
<string name="cordn_create_outcome_open">चर्चा खोलें</string>
|
||||
<string name="cordn_create_outcome_skipped">कोई कुंचिका नहीं यहाँ। इसलिए कोई स्वागत सन्देश छोडा नहीं जा सका</string>
|
||||
<string name="cordn_create_outcome_title">समूह है</string>
|
||||
<string name="cordn_create_outcome_waiting">उनकी प्रतीक्षा में</string>
|
||||
<string name="cordn_create_people_done">हो गया</string>
|
||||
<string name="cordn_create_people_empty">कोई भी जोडे नहीं गए अब तक। समूह रिक्त रूप में बनाया जा सकता है तथा आमन्त्रण तत्पश्चात भेजे जा सकते हैं। पर व्यक्तियों को अब नामित करने से पता चलेगा किस समायोजक उन तक अभिगम्य।</string>
|
||||
<string name="cordn_create_people_none">कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए</string>
|
||||
<string name="cordn_create_people_note">लोगों को नामित अब करने से ही पता चलेगा किस समायोजक उन तक अभिगम्य। प्रत्येक को एक स्वागत प्राप्त होता है जो उस समायोजक पर छोडा जाता है। जिसे वे उठाते हैं अगली बार वे अपने क्रमक खोलते हैं।</string>
|
||||
<plurals name="cordn_create_reach_count">
|
||||
<item quantity="one">अभिगम्य %1$d समायोजक पर</item>
|
||||
<item quantity="other">अभिगम्य %1$d समायोजकों पर</item>
|
||||
@@ -5884,55 +6009,95 @@
|
||||
<string name="cordn_create_step_people">कौन इसमें हैं</string>
|
||||
<string name="cordn_create_step_where">कहाँ उसका आवास</string>
|
||||
<string name="cordn_create_title">नया कोर्डन समूह</string>
|
||||
<plurals name="cordn_create_unreachable_count">
|
||||
<item quantity="one">%1$d व्यक्ति आपके द्वारा उपयुक्त किसी भी समायोजक द्वारा अभिगम्य नहीं</item>
|
||||
<item quantity="other">%1$d व्यक्ति आपके द्वारा उपयुक्त किसी भी समायोजक द्वारा अभिगम्य नहीं</item>
|
||||
</plurals>
|
||||
<string name="cordn_create_you">आप। सर्वदा प्रशासक</string>
|
||||
<string name="cordn_delete_confirm_body">समूह में सभी इस सन्देश को प्रत्याहृत के रूप में देखेंगे। तथा अमेथिस्ट इसका लेख दिखाना रोकेगा। समायोजक के पास रहस्यीकृत अनुकृति रहता है जिस रूप में उसे भेजा गया था। तथा जिसने भी उसे पढा वह उसे पढ चुका।</string>
|
||||
<string name="cordn_delete_confirm_title">सन्देश लौटाएँ</string>
|
||||
<string name="cordn_delivery_accepted">समायोजक द्वारा स्वीकृत</string>
|
||||
<string name="cordn_exposure_close">समझ गया</string>
|
||||
<string name="cordn_exposure_open">यह समायोजक क्या देख सकता है</string>
|
||||
<string name="cordn_group_info">समूह जानकारी</string>
|
||||
<string name="cordn_group_unavailable">यह समूह उपलब्ध नहीं। इसका समायोजक सम्भाव्यतः हटा दिया गया।</string>
|
||||
<string name="cordn_group_unavailable_detail">स्थापना विकल्पों में समायोजक जोडें कोर्डन समूहों का उपयोग आरम्भ करने के लिए।</string>
|
||||
<plurals name="cordn_groups_count">
|
||||
<item quantity="one">%1$d समूह</item>
|
||||
<item quantity="other">%1$d समूह</item>
|
||||
</plurals>
|
||||
<string name="cordn_groups_down_note">इन समूहों में से कुछ भी नहीं आ रहा। अमेथिस्ट पुनःप्रयास करते रहेगा।</string>
|
||||
<string name="cordn_groups_manage">समायोजक</string>
|
||||
<string name="cordn_groups_none">कोई कोर्डन समूह नहीं अब तक</string>
|
||||
<string name="cordn_groups_none_detail">एक कोर्डन समूह एक समायोजक पर जीवित है जो उसकी सदस्यता रखता है। तथा सन्देशों को आगे भेजता है। किसी ने क्या लिखा यह कभी भी देखे बिना।</string>
|
||||
<string name="cordn_groups_start">एक का आरम्भ करें</string>
|
||||
<string name="cordn_info_add_member">किसी को जोडें</string>
|
||||
<string name="cordn_info_add_member_none">उस नाम से कोई प्राप्त नहीं।</string>
|
||||
<string name="cordn_info_add_member_note">जब वे एक कुंचिका पोटली इस समायोजक तक प्रकाशित करेंगे तब ही उन्हें जोडा जा सकता है।</string>
|
||||
<string name="cordn_info_add_member_placeholder">नाम अथवा एनपुब॰ अथवा नाम@जालक्षेत्र</string>
|
||||
<string name="cordn_info_admin_badge">प्रशासक</string>
|
||||
<plurals name="cordn_info_admins_kept">
|
||||
<item quantity="one">समूह का %1$d प्रशासक रहेगा यथास्थिति।</item>
|
||||
<item quantity="other">समूह के %1$d प्रशासक रहेंगे यथास्थिति।</item>
|
||||
</plurals>
|
||||
<string name="cordn_info_coordinator">समायोजक</string>
|
||||
<string name="cordn_info_coordinator_key">समायोजक कुंचिका</string>
|
||||
<string name="cordn_info_coordinator_nprofile">समायोजक योजक</string>
|
||||
<string name="cordn_info_copy_nprofile">समायोजक एनप्रोफैल॰ की अनुकृति</string>
|
||||
<string name="cordn_info_edit_details">विवरण सम्पादन</string>
|
||||
<string name="cordn_info_egalitarian">इस समूह में कोई प्रशासक नहीं। सभी जोड सकते हैं हटा सकते हैं पुनःनामकरण कर सकते हैं स्थायी रूप में।</string>
|
||||
<string name="cordn_info_epoch">युग</string>
|
||||
<string name="cordn_info_gid">समूह परिचायक</string>
|
||||
<string name="cordn_info_has_key_package">इस समायोजक पर जोडा जा सकता है</string>
|
||||
<string name="cordn_info_members">सदस्य</string>
|
||||
<string name="cordn_info_remove_confirm_body">%1$s आगे से सन्देश प्राप्त नहीं करेंगे। जो वे पढ चुके उन्हे वे रखते हैं। तथा इसको पूर्ववत नहीं किया जा सकता। उनको पुनःआमन्त्रित होना पडेगा।</string>
|
||||
<string name="cordn_info_remove_confirm_title">क्या समूह से हटाएँ।</string>
|
||||
<string name="cordn_info_remove_member">हटाएँ</string>
|
||||
<string name="cordn_info_save">अभिलेखन</string>
|
||||
<string name="cordn_info_technical">तन्त्रज्ञानात्मक विवरण</string>
|
||||
<string name="cordn_invitations_accept">जुडें</string>
|
||||
<string name="cordn_invitations_decline">अस्वीकार</string>
|
||||
<string name="cordn_invitations_decline_warning">अस्वीकरण स्थायी है। पुनःभीतर जाने के लिए पुनःआमन्त्रित होना चाहिए।</string>
|
||||
<string name="cordn_invitations_explainer">जाँच की जाती है जब आप इस पटल को खोलते हैं। तथा इसके अतिरिक्त कभी नहीं। समायोजक को पूछने पर कि क्या किसी ने आपको आमन्त्रित किया तब उसे पता चल जाता है कि आप यहाँ हैं। इसलिए अमेथिस्ट पृष्ठभूत होकर इसे नहीं करता।</string>
|
||||
<string name="cordn_invitations_load_failed">आमन्त्रणों को पढने में असफल।</string>
|
||||
<plurals name="cordn_invitations_members">
|
||||
<item quantity="one">%1$d व्यक्ति पहले से है इस समूह में</item>
|
||||
<item quantity="other">%1$d व्यक्ति पहले से हैं इस समूह में</item>
|
||||
</plurals>
|
||||
<string name="cordn_invitations_members_more">स %1$d अधिक</string>
|
||||
<string name="cordn_invitations_no_coordinators">आपने किसी समायोजक को खुला नहीं रखा। कोर्डन आमन्त्रण समायोजक द्वारा आते हैं। इसलिए ढूँढने के लिए कोई स्थान नहीं अब तक।</string>
|
||||
<string name="cordn_invitations_none">कोई आमन्त्रण प्रतीक्षा नहीं कर रहा।</string>
|
||||
<string name="cordn_invitations_refresh">पुनःजाचें</string>
|
||||
<string name="cordn_invitations_skipped">एक आमन्त्रण छोडा गया एक अन्य यन्त्र के लिए</string>
|
||||
<string name="cordn_invitations_title">कोर्डन आमन्त्रण</string>
|
||||
<string name="cordn_invitations_unreachable">%1$s ने उत्तर नहीं दिया</string>
|
||||
<string name="cordn_invitations_via">%1$s द्वारा</string>
|
||||
<string name="cordn_keypackages_disclosure">प्रकाशन से एक अभिलेख हस्ताक्षरित होता है आपकी अपनी लेखा कुंचिका के साथ। तब समायोजक जान जाता है कि यह लेखा है तथा आमन्त्रणशक्य है। स्थायी रूप से। तथा आप को किसी ने आमन्त्रित किया अथवा नहीं इस पर आश्रित नहीं।</string>
|
||||
<string name="cordn_keypackages_explainer">अन्य लोग आपको एक कोर्डन समूह में जोड सकते हैं केवल आपके द्वारा प्रकाशित कुंचिका पोटली को समायोजक तक ले जाने से। कोर्डन में कोई अन्य स्थल नहीं एक को रखने के लिए। इसलिए प्रकाशित नहीं तो कोई भी आपको आमन्त्रण नहीं दे सकते। तथा कुछ भी उनको बताता नहीं कि यह क्यों है।</string>
|
||||
<string name="cordn_keypackages_failed">कुंचिका पोटलियाँ पढने में असफल।</string>
|
||||
<string name="cordn_keypackages_last_resort_no">कोई अन्तिम उपाय पोटली नहीं। एकप्रयोग वाले जब समाप्त हो जाते हैं तब आमन्त्रण असफल हो जाते हैं।</string>
|
||||
<string name="cordn_keypackages_last_resort_yes">एक अन्तिम उपाय पोटली प्रकाशित है। इसलिए एक आमन्त्रण तब भी बनाया जा सकता है सभी एकप्रयोग वाले समाप्त होने के पश्चात।</string>
|
||||
<plurals name="cordn_keypackages_orphans">
|
||||
<item quantity="one">%1$d पोटली जिसे यह यन्त्र नहीं खोल सकता</item>
|
||||
<item quantity="other">%1$d पोटलियाँ जिन्हें यह यन्त्र नहीं खोल सकता</item>
|
||||
</plurals>
|
||||
<string name="cordn_keypackages_orphans_body">समायोजक इसे दे देगा किसी को भी जो आपको आमन्त्रित कर रहा है। पर निजी अर्धभाग इस यन्त्र पर नहीं है। वह एक अन्य स्थापना के पास है। यदि वह स्थापना चली गई तब इसका उपयोग करनेवाले एक आमन्त्रण से एक स्वागत उत्पादित होगा जो किसी से भी कभी भी खोला नहीं जा सकता।</string>
|
||||
<string name="cordn_keypackages_publish">एक का प्रकाशन</string>
|
||||
<string name="cordn_keypackages_publish_last_resort">अन्तिम उपाय का प्रकाशन</string>
|
||||
<plurals name="cordn_keypackages_summary">
|
||||
<item quantity="one">%1$d एकप्रयोग पोटली उपलब्ध।</item>
|
||||
<item quantity="other">%1$d एकप्रयोग पोटलियाँ उपलब्ध।</item>
|
||||
</plurals>
|
||||
<string name="cordn_keypackages_topup_note">एक बार आप यहाँ प्रकाशित करते हैं अमेथिस्ट बिना सूचना पोटलियों को प्रतिस्थापित करते रहेगा जैसे जैसे वे उपयुक्त होते जाते हैं। प्रथम को कभी आप के लिए प्रकाशित नहीं करता।</string>
|
||||
<string name="cordn_keypackages_withdraw_all">सब लौटा लें</string>
|
||||
<string name="cordn_keypackages_withdraw_orphans">उनको लौटा लें</string>
|
||||
<string name="cordn_link_request">इस समूह से जुडने का अनुरोध करें</string>
|
||||
<string name="cordn_link_request_disclosure">यह आपकी ही लेखा कुंचिका से एक कुंचिका पोटली प्रकाशित करता है। तथा समायोजक को कहता है कि आप इस समूह से जुडना चाहते हैं। इस पर आश्रित नहीं कि कोई उत्तर देते हैं अथवा नहीं।</string>
|
||||
<string name="cordn_link_request_failed">जुडने का अनुरोध असफल।</string>
|
||||
<string name="cordn_link_request_sent">पूछा गया। समूह के किसी एक सदस्य को आपको जोडना होगा। आमन्त्रण प्रकट होगा कोर्डन आमन्त्रणों के मध्य।</string>
|
||||
<string name="cordn_link_scan">परखें</string>
|
||||
<string name="cordn_media_download_failed">उस अभिलेख को खोलने में असफल।</string>
|
||||
<string name="cordn_media_no_server">कोई श्रव्यदृश्याभिलेख सेवासंगणक स्थापित नहीं इस लेखा के लिए। इसलिए अभिलेख रखने के लिए कोई स्थान नहीं। स्थापना विकल्पों में एक का चयन करें।</string>
|
||||
<string name="cordn_media_unreadable">वह अभिलेख पठनशक्य नहीं।</string>
|
||||
<string name="cordn_media_upload_failed">उस अभिलेख को भेजने में असफल।</string>
|
||||
<plurals name="cordn_member_count">
|
||||
@@ -5946,12 +6111,17 @@
|
||||
<string name="cordn_message_details_title">सन्देश विवरण</string>
|
||||
<string name="cordn_message_edited">सम्पादित</string>
|
||||
<string name="cordn_pinned_by">टाँका गया %1$s द्वारा</string>
|
||||
<plurals name="cordn_pinned_count">
|
||||
<item quantity="one">%1$d टाँका गया सन्देश इस समूह में</item>
|
||||
<item quantity="other">%1$d टाँके गए सन्देश इस समूह में</item>
|
||||
</plurals>
|
||||
<string name="cordn_pinned_next">अगला टाँका गया सन्देश</string>
|
||||
<string name="cordn_pinned_previous">पिछला टाँका गया सन्देश</string>
|
||||
<string name="cordn_pinned_show_all">सभी टाँके गए सन्देशों को दिखाएँ</string>
|
||||
<string name="cordn_pinned_title">टाँके गए सन्देश</string>
|
||||
<string name="cordn_reactions_title">प्रतिक्रियाएँ</string>
|
||||
<string name="cordn_requests_accept">जोडें</string>
|
||||
<string name="cordn_requests_admin_only">केवल प्रशासक स्वीकार सकता है। एक समूह में जहाँ कोई प्रशासक नहीं वहाँ सभी कर सकते हैं।</string>
|
||||
<string name="cordn_requests_check">अनुरोधों के लिए जाँचें</string>
|
||||
<string name="cordn_requests_decline">हटाएँ</string>
|
||||
<string name="cordn_requests_failed">अनुरोधों को पढने में असफल।</string>
|
||||
@@ -5959,8 +6129,10 @@
|
||||
<string name="cordn_requests_title">जुडने के अनुरोध</string>
|
||||
<string name="cordn_send">भेजें</string>
|
||||
<string name="cordn_send_failed">भेजने में असफल।</string>
|
||||
<string name="cordn_send_no_session">इस समूह का समायोजक अभी तक खुला नहीं। इसलिए अब तक कुछ भी भेज नहीं सकते।</string>
|
||||
<string name="cordn_share_copied">अनुकृत</string>
|
||||
<string name="cordn_share_copy">योजक अनुकृति</string>
|
||||
<string name="cordn_share_explainer">इस योजक के साथ कोई भी जुडने का अनुरोध कर सकता है। तब भी प्रशासक द्वारा भीतर आने की अनुमति आवश्यक।</string>
|
||||
<string name="cordn_share_title">इस समूह को बाँटें</string>
|
||||
<string name="cordn_voice_play">ध्वनि टीका चलाएँ</string>
|
||||
<string name="cordn_voice_record">ध्वनि टीका का अभिलेखन करें</string>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Adjon hozzá legalább egy ládát.</string>
|
||||
<string name="geocache_publish_failed">Nem sikerült közzétenni. Ellenőrizze a kapcsolatot az átjátszóval, és próbálja újra.</string>
|
||||
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Értéket az értékért</string>
|
||||
<string name="relay_monitor_rtt_open">Megnyitás</string>
|
||||
<string name="relay_monitor_rtt_read">Olvasás</string>
|
||||
@@ -653,6 +653,24 @@
|
||||
<string name="mark_all_known_as_read">Az összes ismert megjelölése olvasottként</string>
|
||||
<string name="mark_all_new_as_read">Az összes új üzenet megjelölése olvasottként</string>
|
||||
<string name="mark_all_as_read">Összes megjelölése olvasottként</string>
|
||||
<string name="account_backup_headline">A titkos kulcsa maga a fiókja</string>
|
||||
<string name="account_backup_intro">Bárki, aki rendelkezik vele, Önként tehet közzé bejegyzéseket. Ha elveszíti, senki sem tudja helyreállítani, még az Amethyst sem.</string>
|
||||
<string name="account_backup_tap_to_reveal">Koppintson a felfedéshez</string>
|
||||
<string name="account_backup_write_down_hint">Írja le ebben a sorrendben. A kis- és nagybetű egyaránt működik.</string>
|
||||
<string name="account_backup_qr_code">QR-kód</string>
|
||||
<string name="account_backup_encrypted_title">Jelszóval védett másolat</string>
|
||||
<string name="account_backup_encrypted_password">Jelszó</string>
|
||||
<string name="account_backup_encrypted_body">Egy ncryptsec1…, amely csak az Ön jelszavával működik.</string>
|
||||
<string name="account_backup_password_min_length">Legalább %1$d karakter</string>
|
||||
<string name="account_backup_encrypted_repeat_password">Jelszó ismét</string>
|
||||
<string name="account_backup_encrypted_password_mismatch">A jelszavak nem egyeznek</string>
|
||||
<string name="account_backup_encrypt">Kulcs titkosítása</string>
|
||||
<string name="account_backup_encrypting">Titkosítás…</string>
|
||||
<string name="account_backup_encrypted_saved_hint">Nyugodtan tárolhatja jelszókezelőben vagy felhőjegyzetekben: jelszó nélkül nem nyitható meg, és az elfelejtett jelszó nem állítható helyre.</string>
|
||||
<string name="account_backup_encrypt_again">Másik jelszó használata</string>
|
||||
<string name="account_backup_tip_storage">Tartsa a papírt olyan helyen, ahol csak Ön találhatja meg, vagy mentse a kulcsot jelszókezelőbe.</string>
|
||||
<string name="account_backup_tip_trust">Soha ne illessze be a kulcsát olyan weboldalakba vagy alkalmazásokba, amelyekben nem bízik meg.</string>
|
||||
<string name="account_backup_tip_developers">Az Amethyst fejlesztői soha nem kérik el a kulcsát.</string>
|
||||
<string name="backup_keys_nudge_title">Biztonsági mentés készítése a kulcsokról</string>
|
||||
<string name="backup_keys_nudge_body">A titkos kulcs megadása az egyetlen módja a fiók elérésének. Ha elveszíti, akkor soha nem tudja helyrehozni. Mentse el biztonságos helyre most.</string>
|
||||
<string name="backup_keys_nudge_backup_now">Biztonsági mentés most</string>
|
||||
@@ -1716,6 +1734,14 @@
|
||||
<string name="relay_group_field_name">Név</string>
|
||||
<string name="relay_group_field_about">Leírás</string>
|
||||
<string name="relay_group_field_picture">Csoport képe</string>
|
||||
<string name="relay_group_field_banner">Borítókép webcíme</string>
|
||||
<string name="relay_group_migration_moved_title">Ez a csoport lehet, hogy átköltözött</string>
|
||||
<string name="relay_group_migration_fork_title">Ennek a csoportnak lehet, hogy elágazása van</string>
|
||||
<string name="relay_group_migration_by_admins">Az adminisztrátorai most a(z) %1$s helyen listázzák</string>
|
||||
<string name="relay_group_migration_by_friends">Az Ön által követett emberek most a(z) %1$s helyen listázzák</string>
|
||||
<string name="relay_group_migration_open">Megnyitás ott</string>
|
||||
<string name="relay_group_migration_move">Áthelyezés</string>
|
||||
<string name="relay_group_migration_dismiss">Eltüntetés</string>
|
||||
<string name="relay_group_add_photo">Fénykép hozzáadása</string>
|
||||
<string name="relay_group_change_photo">Fénykép módosítása</string>
|
||||
<string name="relay_group_section_discovery">Felfedezés</string>
|
||||
@@ -2452,6 +2478,8 @@
|
||||
<string name="select_list_to_filter">Szempont kiválasztása a hírfolyam szűréséhez</string>
|
||||
<string name="add_dvm_to_favorites">Hírfolyam-algoritmus hozzáadása a kedvencekhez</string>
|
||||
<string name="remove_dvm_from_favorites">Eltávolítás a kedvencekből</string>
|
||||
<string name="add_follow_set_to_favorites">Követési készlet hozzáadása a kedvenc hírfolyamokhoz</string>
|
||||
<string name="remove_follow_set_from_favorites">Követési készlet eltávolítása a kedvenc hírfolyamokból</string>
|
||||
<string name="favorite_dvms_explainer">A kedvencnek jelölt algoritmusok itt jelennek meg, valamint szűrőelemként a Főoldal hírfolyamában.</string>
|
||||
<string name="favorite_dvms_empty_headline">Kedvenc algoritmus kitűzése</string>
|
||||
<string name="favorite_dvms_empty_step1">Koppintson az alábbi „%1$s” gombra az algoritmusok böngészéséhez.</string>
|
||||
@@ -2672,6 +2700,20 @@
|
||||
<string name="nickname_private">Csak az Ön számára látható</string>
|
||||
<string name="cast_to_device_dialog_title">Közvetítés…</string>
|
||||
<string name="cast_searching_for_devices">Eszközök keresése a Wi-Fi hálózaton…</string>
|
||||
<string name="cast_local_network_permission_title">Engedély szükséges</string>
|
||||
<string name="cast_local_network_permission_message">Az Amethystnek helyi hálózati hozzáférésre van szüksége, hogy megtalálja a Cast eszközöket a Wi-Fi-hálózatán. Engedélyezze az alkalmazás beállításaiban.</string>
|
||||
<string name="cast_local_network_permission_open_settings">Beállítások megnyitása</string>
|
||||
<string name="cast_generic_receiver">A Cast eszköz</string>
|
||||
<string name="cast_error_unsupported_media">A(z) %1$s nem tudja lejátszani az ilyen formátumú videókat</string>
|
||||
<string name="cast_error_playback_failed">%1$s nem tudta lejátszani ezt a videót</string>
|
||||
<string name="cast_error_receiver_not_responding">%1$s nem válaszol. Az eszköz újraindítása megoldhatja a problémát.</string>
|
||||
<string name="cast_error_connect_failed">Nem sikerült kapcsolódni a(z) %1$s eszközhöz. Az eszköz újraindítása általában megoldja.</string>
|
||||
<string name="cast_error_device_offline">%1$s offline állapotba került</string>
|
||||
<string name="cast_error_load_failed">Nem sikerült betölteni ezt a videót a(z) %1$s eszközön</string>
|
||||
<string name="cast_error_play_services_unavailable">A Cast funkcióhoz Google Play szolgáltatásokra van szükség, amelyek nem érhetők el ezen az eszközön</string>
|
||||
<string name="cast_error_unsupported_media_detail">A(z) %1$s nem tudja lejátszani az ilyen formátumú videókat (%2$s)</string>
|
||||
<string name="cast_error_playback_failed_detail">%1$s nem tudta lejátszani ezt a videót (%2$s)</string>
|
||||
<string name="cast_error_receiver_not_responding_detail">%1$s soha nem kezdte el lejátszani ezt a videót (%2$s). Lehet, hogy nem támogatja a formátumot. Az eszköz újraindítása megoldhatja ezt a problémát.</string>
|
||||
<string name="hls_pick_video_primary">Videó kiválasztása</string>
|
||||
<string name="hls_pick_video_helper">A videó több felbontásba lesz átkódolva, így a nézők bármilyen internet-kapcsolaton zökkenőmentes lejátszást kapnak.</string>
|
||||
<string name="hls_change_video">Módosítás</string>
|
||||
@@ -2783,8 +2825,13 @@
|
||||
<string name="relay_management_no_moderation_events">Nincsenek moderálandó események</string>
|
||||
<string name="relay_management_banned_events">Letiltott események</string>
|
||||
<string name="relay_management_no_banned_events">Nincsenek letiltott események</string>
|
||||
<string name="relay_management_allowed_events">Engedélyezett események</string>
|
||||
<string name="relay_management_no_allowed_events">Nincsenek engedélyezett események</string>
|
||||
<string name="relay_management_allow_event">Esemény engedélyezése</string>
|
||||
<string name="relay_management_allowed_kinds">Engedélyezett típusok</string>
|
||||
<string name="relay_management_no_allowed_kinds">Nincsenek engedélyezett típusok</string>
|
||||
<string name="relay_management_disallowed_kinds">Tiltott típusok</string>
|
||||
<string name="relay_management_no_disallowed_kinds">Nincsenek tiltott típusok</string>
|
||||
<string name="relay_management_blocked_ips">Letiltott IP-címek</string>
|
||||
<string name="relay_management_no_blocked_ips">Nincsenek letiltott IP-címek</string>
|
||||
<string name="relay_management_add">Hozzáadás</string>
|
||||
@@ -2817,6 +2864,9 @@
|
||||
<string name="relay_members_join_sent">Csatlakozási kérés elküldve</string>
|
||||
<string name="relay_members_leave_sent">Elhagyási kérés elküldve</string>
|
||||
<string name="relay_members_you_are_member">Ön tagja az átjátszónak</string>
|
||||
<string name="relay_members_invite_code">Meghívókód</string>
|
||||
<string name="relay_members_invite_code_hint">Ez az átjátszó az üzemeltetőjétől származó meghívókóddal vesz fel tagokat</string>
|
||||
<string name="relay_members_unverifiable">Ez az átjátszó nem teszi közzé az azonosítókulcsát (NIP-11 self), így a taglistája nem ellenőrizhető</string>
|
||||
<string name="relay_membership_list">Átjátszó tagsági listája</string>
|
||||
<string name="relay_member_added">A tag hozzá lett adva az átjátszóhoz</string>
|
||||
<string name="relay_members_added">%1$d tag hozzáadva az átjátszóhoz</string>
|
||||
@@ -3088,7 +3138,7 @@
|
||||
<item quantity="other">%1$d átjátszó</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">előfizetések, szűrők, átjátszók, kérések, kapcsolatok, miért diagnosztika</string>
|
||||
<string name="active_subs_share">%1$d%% az összesből</string>
|
||||
<string name="active_subs_share">%1$d% az összesből</string>
|
||||
<string name="active_subs_title">Aktív átjátszó-előfizetések</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d szűrő még nincs hozzárendelve</item>
|
||||
@@ -4556,6 +4606,7 @@
|
||||
<string name="podcast_trailer_upload_hint">Rövid hang vagy -videó előnézete</string>
|
||||
<string name="podcast_value_error_title">Értéket az értékért hiba</string>
|
||||
<string name="podcast_value_keysend_requires_nwc">Párosítson egy Nostr Wallet Connect pénztárcát a keysend (csomópont) címzetteknek való küldéshez.</string>
|
||||
<string name="podcast_value_keysend_not_supported">A saját pénztárcája nem támogatja a keysendet, így a csomópont-címzettek kimaradtak.</string>
|
||||
<string name="podcast_value_no_recipients">Ez a hangműsor nem rendelkezik fizethető kedvezményezettel.</string>
|
||||
<string name="podcast_value_stream_requires_wallet">Párosítson egy Nostr Wallet Connect vagy debit pénztárcát a satok közvetítéséhez hallgatás közben.</string>
|
||||
<string name="podcast_value_user_no_lnaddress">Ennek a felhasználónak nincs lightning-címe</string>
|
||||
@@ -5134,7 +5185,7 @@
|
||||
<string name="upload_error_voice_message_exception">Nem sikerült feltölteni a hangüzenetet: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Nem sikerült feltölteni a hangüzenetet</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">A NIP-95 még nem támogatja a hangüzeneteket</string>
|
||||
<string name="uptime">Üzemidő: %1$d%%</string>
|
||||
<string name="uptime">Üzemidő: %1$d%</string>
|
||||
<string name="use_internal_tor">Aktív Tor-motor</string>
|
||||
<string name="use_internal_tor_explainer">Használja a beépített verziót vagy az Orbotot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó nem rendelkezik a satoshik fogadásához beállított lightning-címmel</string>
|
||||
@@ -5187,6 +5238,7 @@
|
||||
</plurals>
|
||||
<string name="wallet_transactions_load_failed">Nem sikerült betölteni a tranzakciót</string>
|
||||
<string name="wallet_transactions_load_more_failed">Nem sikerült betölteni további tranzakciókat</string>
|
||||
<string name="wallet_transactions_not_supported">A saját pénztárcája nem kínál tranzakció-előzményeket</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Figyelmeztető üzenetet jelenít meg, ha az Ön követői bejelentést tettek bejegyzésekről vagy profilokról</string>
|
||||
<string name="warn_when_posts_have_reports_from_your_follows_title">Figyelmeztetés a jelentésekre</string>
|
||||
<string name="web_bookmark_add_title">Webes könyvjelző hozzáadása</string>
|
||||
@@ -5248,6 +5300,10 @@
|
||||
<string name="cordn_health_unknown">Még nem jött létra a kapcsolat</string>
|
||||
<string name="cordn_health_ok">Válaszol</string>
|
||||
<string name="cordn_health_down">Nem válaszol</string>
|
||||
<plurals name="cordn_health_failures">
|
||||
<item quantity="one">%1$d sikertelen kísérlet egymás után</item>
|
||||
<item quantity="other">%1$d sikertelen kísérlet egymás után</item>
|
||||
</plurals>
|
||||
<string name="cordn_link_explainer">Illesszen be egy Önnel megosztott cordn1… hivatkozást, hogy lássa, melyik koordinátor továbbítja azt a csoportot, és mit tudna meg az az üzemeltető Önről, ha csatlakozna.</string>
|
||||
<string name="cordn_link_field">cordn1…</string>
|
||||
<string name="cordn_link_inspect">Vizsgálat</string>
|
||||
@@ -5336,6 +5392,10 @@
|
||||
<string name="cordn_migrate_import">Csoportok átköltöztetése ide</string>
|
||||
<string name="cordn_migrate_importing">Lekérés…</string>
|
||||
<string name="cordn_migrate_replaces_warning">Ez lecseréli az eszközön már meglévő cordn csoportokat. Ezek nem olvaszthatók egybe.</string>
|
||||
<plurals name="cordn_migrate_done">
|
||||
<item quantity="one">%1$d csoport áthelyezve</item>
|
||||
<item quantity="other">%1$d csoport áthelyezve</item>
|
||||
</plurals>
|
||||
<string name="cordn_migrate_no_groups">Nincsenek átköltöztethető cordn csoportok ezen az eszközön.</string>
|
||||
<string name="cordn_migrate_no_server">Először állítson be médiakiszolgálót, a titkosított dokumentumoknak valahol lenniük kell, amíg a másik telefon le nem kéri őket.</string>
|
||||
<string name="cordn_migrate_exposure_title">Mi hagyja el ezt az eszközt</string>
|
||||
@@ -5856,7 +5916,15 @@
|
||||
<string name="cordn_coordinators_discover_none">Senki sem jelentkezik be az átjátszóin.</string>
|
||||
<string name="cordn_coordinators_discover_seen">Bejelentve ennyi ideje: %1$s</string>
|
||||
<string name="cordn_coordinators_discover_seen_on">Utoljára bejelentve: %1$s</string>
|
||||
<plurals name="cordn_coordinators_discover_unheard">
|
||||
<item quantity="one">Semmi sem található, és az átjátszói közül %1$d nem válaszolt.</item>
|
||||
<item quantity="other">Semmi sem található, és az átjátszói közül %1$d nem válaszolt.</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_explainer">Egy koordinátor egy csoport üzeneteit rendezi sorba, és ez az egyetlen, ami ezt megteheti. Ha elvész, a sorrend is elvész, és egy második nem tükrözi az elsőt; így ezek névvel ellátott dolgok, amelyeket Ön választ, nem pedig felcserélhető átjátszók.</string>
|
||||
<plurals name="cordn_coordinators_health_down">
|
||||
<item quantity="one">Nem válaszol – %1$d sikertelen hívás egymás után. A rajta lévő csoportok nem szinkronizálódnak.</item>
|
||||
<item quantity="other">Nem válaszol – %1$d hívás egymás után sikertelen. A rajta lévő csoportok nem szinkronizálódnak.</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_health_ok">Válaszol.</string>
|
||||
<string name="cordn_coordinators_health_retrying">Egy hívás sikertelen volt; újrapróbálkozás.</string>
|
||||
<string name="cordn_coordinators_health_unknown">Még semmit sem kérdeztek tőle.</string>
|
||||
@@ -5880,6 +5948,10 @@
|
||||
<string name="cordn_coordinators_server_silent">Válaszolt, de semmit sem nevezett meg.</string>
|
||||
<string name="cordn_coordinators_show_all">Mind a(z) %1$d megjelenítése</string>
|
||||
<string name="cordn_coordinators_show_fewer">Kevesebb megjelenítése</string>
|
||||
<plurals name="cordn_coordinators_show_older">
|
||||
<item quantity="one">%1$d bejelentést abbahagyó megjelenítése</item>
|
||||
<item quantity="other">%1$d bejelentést abbahagyó megjelenítése</item>
|
||||
</plurals>
|
||||
<string name="cordn_coordinators_stale_note">Ezek több mint egy hónapja nem jelentkeztek be. A csoport létrehozása egy eltűnt koordinátoron sikertelen lesz.</string>
|
||||
<string name="cordn_create_action">Csoport létrehozása</string>
|
||||
<plurals name="cordn_create_action_invite">
|
||||
@@ -5901,6 +5973,10 @@
|
||||
<string name="cordn_create_coordinator_new">Egy másik koordinátor</string>
|
||||
<string name="cordn_create_coordinator_pubkey">Koordinátor nyilvános kulcsa (hex vagy npub)</string>
|
||||
<string name="cordn_create_coordinator_relays">Átjátszók, amelyeken válaszol, soronként egy</string>
|
||||
<plurals name="cordn_create_coverage_all">
|
||||
<item quantity="one">Elér %1$d embert</item>
|
||||
<item quantity="other">Elér %1$d embert</item>
|
||||
</plurals>
|
||||
<string name="cordn_create_coverage_partial">%2$d / %1$d embert érne el, a többi kimaradna</string>
|
||||
<string name="cordn_create_coverage_unknown">Nincs ellenőrizve; a rendszer rá fog kérdezni a kiválasztására</string>
|
||||
<string name="cordn_create_description">Leírás (nem kötelező)</string>
|
||||
|
||||
@@ -2915,7 +2915,7 @@
|
||||
<string name="upload_error_voice_message_exception">Unggahan suara gagal: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Gagal mengunggah pesan suara</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 belum didukung untuk pesan suara</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="uptime">%1$d% uptime</string>
|
||||
<string name="use_internal_tor">Mesin Tor Aktif</string>
|
||||
<string name="use_internal_tor_explainer">Gunakan versi internal atau Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Pengguna tidak memiliki alamat lightning (LN) yang diatur untuk menerima sats</string>
|
||||
|
||||
@@ -2841,7 +2841,7 @@
|
||||
<string name="upload_error_voice_message_exception">Caricamento vocale fallito: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Impossibile caricare il messaggio vocale</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 non è ancora supportato per i messaggi vocali</string>
|
||||
<string name="uptime">%1$d%% di uptime</string>
|
||||
<string name="uptime">%1$d% di uptime</string>
|
||||
<string name="use_internal_tor">Motore Tor attivo</string>
|
||||
<string name="use_internal_tor_explainer">Usa la versione interna o Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L'utente non dispone di un indirizzo Lightning per ricevere sats</string>
|
||||
|
||||
@@ -2861,7 +2861,7 @@
|
||||
<string name="upload_error_voice_message_exception">ボイスのアップロードに失敗しました: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</string>
|
||||
<string name="uptime">%1$d%%稼働率</string>
|
||||
<string name="uptime">%1$d%稼働率</string>
|
||||
<string name="use_internal_tor">Tor エンジンを有効化</string>
|
||||
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">satsを受け取るためのLightningアドレスが設定されていません</string>
|
||||
|
||||
@@ -2915,7 +2915,7 @@
|
||||
<string name="upload_error_voice_message_exception">ボイスのアップロードに失敗しました: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">ボイスメッセージのアップロードに失敗しました</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 はボイスメッセージにまだ対応していません</string>
|
||||
<string name="uptime">%1$d%%稼働率</string>
|
||||
<string name="uptime">%1$d%稼働率</string>
|
||||
<string name="use_internal_tor">Tor エンジンを有効化</string>
|
||||
<string name="use_internal_tor_explainer">内蔵版または Orbot を使用</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">satsを受け取るためのLightningアドレスが設定されていません</string>
|
||||
|
||||
@@ -2846,7 +2846,7 @@
|
||||
<string name="upload_error_voice_message_exception">음성 업로드 실패: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">음성 메시지 업로드에 실패했습니다</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95는 아직 음성 메시지를 지원하지 않습니다</string>
|
||||
<string name="uptime">%1$d%% 업타임</string>
|
||||
<string name="uptime">%1$d% 업타임</string>
|
||||
<string name="use_internal_tor">내장 Tor 엔진</string>
|
||||
<string name="use_internal_tor_explainer">내장 버전 또는 Orbot을 사용하세요</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">이 사용자는 sats를 받을 Lightning 주소가 설정되어 있지 않습니다</string>
|
||||
|
||||
@@ -2930,7 +2930,7 @@
|
||||
<string name="upload_error_voice_message_exception">Balss augšupielāde neizdevās: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Neizdevās augšupielādēt balss ziņojumu</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 balss ziņojumiem vēl netiek atbalstīts</string>
|
||||
<string name="uptime">%1$d%% darbalaiks</string>
|
||||
<string name="uptime">%1$d% darbalaiks</string>
|
||||
<string name="use_internal_tor">Aktīvs Tor dzinējs</string>
|
||||
<string name="use_internal_tor_explainer">Izmantot iekšējo versiju vai Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Lietotājs nav iestatījis lightning adresi sats saņemšanai</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="uptime">%1$d% uptime</string>
|
||||
<string name="use_internal_tor">Interne Tor-engine gebruiken</string>
|
||||
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<string name="road_event_unknown">Weggebeurtenis</string>
|
||||
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
|
||||
<string name="podcast_value_zap_split_hint">Zaps hiervoor worden verdeeld over:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Value-for-Value</string>
|
||||
<string name="relay_monitor_rtt_open">Openen</string>
|
||||
<string name="relay_monitor_rtt_read">Lezen</string>
|
||||
@@ -2615,7 +2615,7 @@
|
||||
<item quantity="other">%1$d relays</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">abonnementen filters relays verzoeken reqs verbindingen waarom diagnostiek</string>
|
||||
<string name="active_subs_share">%1$d%% van alles</string>
|
||||
<string name="active_subs_share">%1$d% van alles</string>
|
||||
<string name="active_subs_title">Actieve relay-abonnementen</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter is nog niet toegewezen</item>
|
||||
@@ -4567,7 +4567,7 @@
|
||||
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="uptime">%1$d% uptime</string>
|
||||
<string name="use_internal_tor">Interne Tor-engine gebruiken</string>
|
||||
<string name="use_internal_tor_explainer">Gebruik de ingebouwde versie van Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Deze gebruiker heeft geen Lightning-adres ingesteld om sats te ontvangen</string>
|
||||
|
||||
@@ -2952,7 +2952,7 @@
|
||||
<string name="upload_error_voice_message_exception">Spraakbericht uploaden mislukt: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Spraakbericht uploaden mislukt</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 wordt nog niet ondersteund voor spraakberichten</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="uptime">%1$d% uptime</string>
|
||||
<string name="use_internal_tor">Actieve Tor Engine</string>
|
||||
<string name="use_internal_tor_explainer">Gebruik de interne versie van Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Gebruiker heeft geen Lightning Adress ingesteld om sats te ontvangen</string>
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Dodaj co najmniej jedną skrzynkę.</string>
|
||||
<string name="geocache_publish_failed">Nie można opublikować. Sprawdź połączenie z transmiterem i spróbuj ponownie.</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps do tego są podzielone między:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Wartość-za-wartość</string>
|
||||
<string name="relay_monitor_rtt_open">Otwórz</string>
|
||||
<string name="relay_monitor_rtt_read">Odczyt</string>
|
||||
@@ -3144,7 +3144,7 @@
|
||||
<item quantity="other">%1$d transmitery</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">subskrypcje filtry przekaźniki, żądania (reqs) połączenia dlaczego diagnostyka</string>
|
||||
<string name="active_subs_share">%1$d%% z wszystkich</string>
|
||||
<string name="active_subs_share">%1$d% z wszystkich</string>
|
||||
<string name="active_subs_title">Aktywne subskrypcje transmitera</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtr nie został jeszcze przypisany</item>
|
||||
@@ -5382,7 +5382,7 @@
|
||||
<string name="upload_error_voice_message_exception">Nie udało się przesłać głosu: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Nie udało się przesłać wiadomości głosowej</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 nie jest jeszcze dostępny dla wiadomości głosowych</string>
|
||||
<string name="uptime">Czas działania %1$d%%</string>
|
||||
<string name="uptime">Czas działania %1$d%</string>
|
||||
<string name="use_internal_tor">Aktywny silnik Tor</string>
|
||||
<string name="use_internal_tor_explainer">Użyj wersji wewnętrznej lub Orbota</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze</string>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Adicione pelo menos um cache.</string>
|
||||
<string name="geocache_publish_failed">Não foi possível publicar. Verifique sua conexão com os relays e tente novamente.</string>
|
||||
<string name="podcast_value_zap_split_hint">Os zaps para isto são divididos entre:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Valor-para-Valor</string>
|
||||
<string name="relay_monitor_rtt_open">Abrir</string>
|
||||
<string name="relay_monitor_rtt_read">Leitura</string>
|
||||
@@ -3088,7 +3088,7 @@
|
||||
<item quantity="other">%1$d Relés</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">assinaturas subscriptions filtros relays requisições reqs conexões por que diagnóstico</string>
|
||||
<string name="active_subs_share">%1$d%% de todos</string>
|
||||
<string name="active_subs_share">%1$d% de todos</string>
|
||||
<string name="active_subs_title">Assinaturas de relay ativas</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filtro ainda não foi atribuído</item>
|
||||
@@ -5134,7 +5134,7 @@
|
||||
<string name="upload_error_voice_message_exception">Falha no upload de voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Falha ao enviar mensagem de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">O NIP-95 ainda não é suportado para mensagens de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidade</string>
|
||||
<string name="uptime">%1$d% de disponibilidade</string>
|
||||
<string name="use_internal_tor">Motor Tor Ativo</string>
|
||||
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
|
||||
|
||||
@@ -2861,7 +2861,7 @@
|
||||
<string name="upload_error_voice_message_exception">Falha no upload de voz: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Falha ao enviar mensagem de voz</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">O NIP-95 ainda não é suportado para mensagens de voz</string>
|
||||
<string name="uptime">%1$d%% de disponibilidade</string>
|
||||
<string name="uptime">%1$d% de disponibilidade</string>
|
||||
<string name="use_internal_tor">Motor Tor Ativo</string>
|
||||
<string name="use_internal_tor_explainer">Use a versão interna ou o Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Usuário não tem um endereço lightning configurado para receber sats</string>
|
||||
|
||||
@@ -2948,7 +2948,7 @@
|
||||
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Неудалось загрузить голосовое сообщение</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
|
||||
<string name="uptime">%1$d%% времени работы</string>
|
||||
<string name="uptime">%1$d% времени работы</string>
|
||||
<string name="use_internal_tor">Встроенный движок Tor</string>
|
||||
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</string>
|
||||
|
||||
@@ -2930,7 +2930,7 @@
|
||||
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
|
||||
<string name="uptime">%1$d%% времени работы</string>
|
||||
<string name="uptime">%1$d% времени работы</string>
|
||||
<string name="use_internal_tor">Встроенный движок Tor</string>
|
||||
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">У пользователя не настроен Lightning-адрес для получения sats</string>
|
||||
|
||||
@@ -3026,7 +3026,7 @@
|
||||
<string name="upload_error_voice_message_exception">Ошибка загрузки голосового сообщения: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Не удалось загрузить голосовое сообщение</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 пока не поддерживается для голосовых сообщений</string>
|
||||
<string name="uptime">%1$d%% времени работы</string>
|
||||
<string name="uptime">%1$d% времени работы</string>
|
||||
<string name="use_internal_tor">Встроенный движок Tor</string>
|
||||
<string name="use_internal_tor_explainer">Использовать встроенную версию или Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Пользователь не установил Lightning адрес для получения чаевых</string>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<string name="road_event_unknown">Dogodek na cesti</string>
|
||||
<!-- NIP-CC geocaching (kinds 37516 / 7516) -->
|
||||
<string name="podcast_value_zap_split_hint">Zapi bojo razdeljeni med:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Vrednost za vrednost</string>
|
||||
<string name="relay_monitor_rtt_open">Odprto</string>
|
||||
<string name="relay_monitor_rtt_read">Branje</string>
|
||||
@@ -2800,7 +2800,7 @@
|
||||
<item quantity="other">%1$d relejev</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">naročnine, filtri, releji, zahteve, povezave, zakaj, diagnostika</string>
|
||||
<string name="active_subs_share">%1$d%% od vseh</string>
|
||||
<string name="active_subs_share">%1$d% od vseh</string>
|
||||
<string name="active_subs_title">Aktivne rele naročnine</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter še ni dodeljen</item>
|
||||
@@ -4940,7 +4940,7 @@
|
||||
<string name="upload_error_voice_message_exception">Neuspešno nalaganje zvočnega posnetka: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Neuspešno nalaganje zvočnega sporočila</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 še ne podpira zvočnih posnetkov</string>
|
||||
<string name="uptime">%1$d%% časa delovanja</string>
|
||||
<string name="uptime">%1$d% časa delovanja</string>
|
||||
<string name="use_internal_tor">Aktivni Tor stroj</string>
|
||||
<string name="use_internal_tor_explainer">Uporabi interno različico ali Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uporabnik nima nastavljenega "lightning" naslova za sprejem satoshi-jev</string>
|
||||
|
||||
@@ -2907,7 +2907,7 @@
|
||||
<string name="upload_error_voice_message_exception">Otpremanje glasa nije uspelo: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Otpremanje glasovne poruke nije uspelo</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 još nije podržan za glasovne poruke</string>
|
||||
<string name="uptime">%1$d%% dostupnost</string>
|
||||
<string name="uptime">%1$d% dostupnost</string>
|
||||
<string name="use_internal_tor">Aktivan Tor motor</string>
|
||||
<string name="use_internal_tor_explainer">Koristite internu verziju ili Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Korisnik nema podešenu Lightning adresu za primanje sats-a</string>
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
<string name="geocache_hunt_needs_caches">Lägg till minst en cache.</string>
|
||||
<string name="geocache_publish_failed">Kunde inte publicera. Kontrollera reläanslutningen och försök igen.</string>
|
||||
<string name="podcast_value_zap_split_hint">Zaps till detta fördelas mellan:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_split_percent">%1$d%</string>
|
||||
<string name="podcast_value_for_value">Värde -för-värde</string>
|
||||
<string name="relay_monitor_rtt_open">Öppna</string>
|
||||
<string name="relay_monitor_rtt_read">Läs</string>
|
||||
@@ -3088,7 +3088,7 @@
|
||||
<item quantity="other">%1$d reläer</item>
|
||||
</plurals>
|
||||
<string name="active_subs_search_keywords">prenumerationer subscriptions filter reläer relay förfrågningar reqs anslutningar varför diagnostik</string>
|
||||
<string name="active_subs_share">%1$d %% av alla</string>
|
||||
<string name="active_subs_share">%1$d % av alla</string>
|
||||
<string name="active_subs_title">Aktiva reläprenumerationer</string>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d filter är inte kopplat ännu</item>
|
||||
@@ -5134,7 +5134,7 @@
|
||||
<string name="upload_error_voice_message_exception">Uppladdning av röst misslyckades: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Misslyckades med att ladda upp röstmeddelandet</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 stöds ännu inte för röstmeddelanden</string>
|
||||
<string name="uptime">%1$d%% drifttid</string>
|
||||
<string name="uptime">%1$d% drifttid</string>
|
||||
<string name="use_internal_tor">Aktiv Tor Engine</string>
|
||||
<string name="use_internal_tor_explainer">Använd den interna versionen eller Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
|
||||
|
||||
@@ -2882,7 +2882,7 @@
|
||||
<string name="upload_error_voice_message_exception">Upakiaji wa sauti umeshindwa: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">Imeshindwa kupakia ujumbe wa sauti</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">NIP-95 haijatarajiwa kwa ujumbe wa sauti bado</string>
|
||||
<string name="uptime">%1$d%% wakati wa uendeshaji</string>
|
||||
<string name="uptime">%1$d% wakati wa uendeshaji</string>
|
||||
<string name="use_internal_tor">Injini ya Tor Inayofanya Kazi</string>
|
||||
<string name="use_internal_tor_explainer">Tumia toleo la ndani au Orbot</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Mtumiaji hana anwani ya umeme iliyoandaliwa kupokea sati</string>
|
||||
|
||||
@@ -2870,7 +2870,7 @@
|
||||
<string name="upload_error_voice_message_exception">குரல் பதிவேற்றம் தோல்வியடைந்தது: %1$s</string>
|
||||
<string name="upload_error_voice_message_failed">குரல் செய்தியை பதிவேற்ற தோல்வியடைந்தது</string>
|
||||
<string name="upload_error_voice_message_nip95_not_supported">குரல் செய்திகளுக்கு NIP-95 இன்னும் ஆதரிக்கப்படவில்லை</string>
|
||||
<string name="uptime">%1$d%% இயக்க நேரம்</string>
|
||||
<string name="uptime">%1$d% இயக்க நேரம்</string>
|
||||
<string name="use_internal_tor">செயலில் Tor எஞ்சின்</string>
|
||||
<string name="use_internal_tor_explainer">உள்ளமைக்கப்பட்ட பதிப்பு அல்லது Orbot பயன்படுத்தவும்</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user