Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779

This commit is contained in:
Claude
2026-07-11 15:04:57 +00:00
98 changed files with 3452 additions and 301 deletions
+19
View File
@@ -278,6 +278,25 @@ call does not guarantee lint finishes first):
The reports persist under each module's `build/reports/`, so re-run lint only
when you want fresh lint data in the next scan.
### 5. Optional: include test coverage
Coverage comes from [Kover](https://github.com/Kotlin/kotlinx-kover). The root
`koverXmlReportCoverage` task runs the unit-test suites of the covered modules
(`quartz`, `commons`, `quic`, `nestsClient`, `geode`, `cli`, `relayBench`,
`desktopApp`, and `amethyst`'s `fdroidDebug` variant) and merges the result
into `build/reports/kover/reportCoverage.xml`, which the scanner imports
automatically when it exists. Same recipe as lint — generate first, scan
second:
```bash
./gradlew koverXmlReportCoverage
./gradlew sonar
```
For a human-readable version of the same data, run
`./gradlew koverHtmlReportCoverage` and open
`build/reports/kover/htmlCoverage/index.html`.
Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any
[analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/)
can be set there. `sonar.projectKey` / `sonar.projectName` default to the root
+1
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
alias(libs.plugins.kotlinxKover)
}
fun getCurrentBranch(workingDir: java.io.File): String =
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account
@@ -67,8 +68,6 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -106,6 +105,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
@@ -251,6 +252,23 @@ class AppModules(
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
// Network identity change (same trigger as Tor's onNetworkChange above), but for DNS
// the response is deliberately SOFT: most cached answers are still correct on the new
// network, so staleAll() keeps serving every one of them and merely re-verifies —
// positives revalidate in the background on next use, negatives (e.g. hosts that only
// failed because the OLD network / captive portal couldn't resolve them) get re-tried
// on first touch instead of waiting out their TTL. Nothing is dropped, nothing blocks.
init {
applicationIOScope.launch {
connManager.status
.map { (it as? ConnectivityStatus.Active)?.networkId }
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { surgeDns.staleAll() }
}
}
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
@@ -601,9 +619,12 @@ class AppModules(
)
// fire-and-forget NIP-13 mining: posts queue here and publish when mined.
// Capped worker pool so a burst of sends never spawns unbounded miners.
// Template jobs checkpoint to disk (restored on login) and every enqueue
// raises the shortService shield so backgrounding doesn't freeze a miner.
// One job mines at a time, racing half the cores over disjoint nonce
// slices — same total CPU budget as the old 2-job pool, but each post
// finishes ~minerThreads× sooner and the other half of the cores stays
// free for the UI. Template jobs checkpoint to disk (restored on login)
// and every enqueue raises the shortService shield so backgrounding
// doesn't freeze a miner.
val powJobStore by lazy {
PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope)
}
@@ -611,7 +632,8 @@ class AppModules(
val powPublishQueue by lazy {
PoWPublishQueue(
scope = applicationIOScope,
maxConcurrent = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 2),
maxConcurrent = 1,
minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()),
persistence = powJobStore,
onQueueActive = { PowMiningForegroundService.start(appContext) },
)
@@ -875,6 +875,14 @@ class Account(
else -> overrideDifficulty
}
/**
* Parallel workers a single nonce search should use — the mining queue's
* per-job budget (half the device's cores). 1 when no queue is wired.
* Callers that run [PoWMiner] inside a queued job must pass this so the
* job stays inside the queue's CPU budget.
*/
fun powMinerWorkers(): Int = powQueue()?.minerThreads ?: 1
/**
* Enqueues [work] into the fire-and-forget mining queue. Returns false when
* no queue is wired (headless/test accounts): callers must then run their
@@ -983,7 +991,9 @@ class Account(
mine = { isActive ->
// the wrap's ephemeral key is generated inside the wrap build;
// the conversion hook hands its pubkey back so the nonce can
// commit to it.
// commit to it. Single-threaded on purpose: the conversion is
// a non-suspend hook deep inside the synchronous NIP-59 wrap
// build, so it can't race PoWMiner.mine workers.
val mineWrap: GiftWrapTemplateConversion = { template, ephemeralPubKey ->
PoWMiner.run(template, ephemeralPubKey, difficulty, isActive)
}
@@ -1015,14 +1025,15 @@ class Account(
isActive: () -> Boolean,
): NostrSigner {
val currentSigner = signer
val workers = powMinerWorkers()
return if (currentSigner is NostrSignerWithClientTag) {
NostrSignerWithClientTag(
inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive),
inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers),
clientTag = currentSigner.clientTag,
disabled = currentSigner.disabled,
)
} else {
PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive)
PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers)
}
}
@@ -3249,7 +3260,12 @@ class Account(
// process death mid-mine cannot lose the file announcement.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
return
}
}
broadcastPrivately(NIP17Factory().createEncryptedFileNIP17(template, signer))
@@ -3261,7 +3277,12 @@ class Account(
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
return
}
}
broadcastPrivately(NIP17Factory().createMessageNIP17(template, signer))
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import okhttp3.Call
import okhttp3.EventListener
import java.io.IOException
import java.net.UnknownHostException
/**
* Drops a host's [SurgeDns] entry whenever an OkHttp call to it fails outright. We hook
@@ -31,6 +33,12 @@ import java.io.IOException
* latter while OkHttp recovers via the next address, and we don't want to invalidate the cache
* just because the first IP was unreachable.
*
* A failure caused by the DNS LOOKUP ITSELF ([UnknownHostException]) must NOT invalidate:
* this hook exists to drop STALE POSITIVES (rotated IPs that no longer connect), but for a
* resolution failure the freshly-written negative entry IS the useful cache state — deleting
* it milliseconds after creation re-pays the full 10-30s resolver timeout on every re-dial
* and silently defeats the negative TTL entirely.
*
* Used by the relay client. The media path uses [MediaCallEventListener], which folds the same
* invalidation into its `finish` method alongside its existing timing logging.
*/
@@ -41,6 +49,7 @@ class DnsInvalidatingEventListener(
call: Call,
ioe: IOException,
) {
if (ioe.causedByUnknownHost()) return
dns.invalidate(call.request().url.host)
}
@@ -53,3 +62,19 @@ class DnsInvalidatingEventListener(
override fun create(call: Call): EventListener = listener
}
}
/**
* True when [UnknownHostException] appears anywhere in the cause chain — OkHttp usually
* throws it directly from `callFailed`, but interceptors may wrap it. Depth-capped against
* pathological cause cycles.
*/
fun Throwable.causedByUnknownHost(): Boolean {
var t: Throwable? = this
var depth = 0
while (t != null && depth < 8) {
if (t is UnknownHostException) return true
t = t.cause
depth++
}
return false
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call
import okhttp3.ConnectionPool
@@ -129,7 +130,10 @@ class MediaCallEventListener(
// Drop the cached entry so the next attempt re-resolves instead of trying the same
// dead IPs for up to 24h. Per-attempt connectFailed isn't enough — a multi-A-record
// host can have one bad IP and OkHttp will recover by trying the next one.
if (error != null) {
// EXCEPT when the failure is the DNS lookup itself: the freshly-written negative
// entry IS the useful cache state, and deleting it would defeat the negative TTL
// (see DnsInvalidatingEventListener).
if (error != null && !error.causedByUnknownHost()) {
dns.invalidate(host)
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
@@ -22,10 +22,21 @@ package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import kotlin.math.roundToLong
/**
* This device's effective mining rate: the [PoWEstimator] benchmark run with
* the same worker count the mining queue uses (half the cores, see
* [PoWPolicy.minerWorkers] and AppModules.powPublishQueue). Every UI estimate
* must use this rate, or it would describe a single-threaded miner that no
* longer exists. Cached after the first call (~250 ms).
*/
suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()))
/**
* "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering
* of a PoW duration estimate, shared by the settings picker, the composer
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus
import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatTimeLeft
import com.vitorpamplona.amethyst.service.pow.powKindLabelRes
import com.vitorpamplona.amethyst.ui.stringRes
@@ -197,7 +198,7 @@ private fun MiningContent(
val context = LocalContext.current
val hashRate by
produceState<Double?>(initialValue = null) {
value = PoWEstimator.hashesPerSecond()
value = deviceHashesPerSecond()
}
Column(modifier = Modifier.fillMaxWidth()) {
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatApproxDuration
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@@ -117,7 +118,7 @@ fun PowOverrideButton(
val context = LocalContext.current
val hashRate by
produceState<Double?>(initialValue = null) {
value = PoWEstimator.hashesPerSecond()
value = deviceHashesPerSecond()
}
fun eta(difficulty: Int): String? = hashRate?.let { formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, it)) }
@@ -64,19 +64,19 @@ fun DisplayPoW(pow: Int) {
Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 6.dp, vertical = 1.dp),
.padding(horizontal = 5.dp, vertical = 1.dp),
) {
Icon(
symbol = MaterialSymbols.Manufacturing,
contentDescription = stringRes(R.string.pow_settings_title),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(12.dp),
modifier = Modifier.size(10.dp),
)
Text(
text = pow.toString(),
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontSize = 12.sp,
lineHeight = 12.sp,
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
@@ -575,7 +575,7 @@ open class CommentPostViewModel :
// fresh created_at at mining start (NIP-13 recommendation):
// the job may have waited in the queue behind other posts.
val fresh = EventTemplate<Event>(TimeUtils.now(), template.kind, template.tags, template.content)
val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive)
val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive)
accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
@@ -1056,7 +1056,7 @@ open class ShortNotePostViewModel :
// fresh created_at at mining start (NIP-13 recommendation):
// the job may have waited in the queue behind other posts.
val fresh = EventTemplate<Event>(TimeUtils.now(), template.kind, template.tags, template.content)
val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive)
val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive)
accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner)
accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete)
}
@@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.model.AccountPoWPreferences
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond
import com.vitorpamplona.amethyst.service.pow.formatApproxDuration
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
@@ -195,7 +196,7 @@ private fun PowTimeEstimate(difficulty: Int) {
val context = LocalContext.current
val estimate by
produceState<String?>(initialValue = null, difficulty) {
val rate = PoWEstimator.hashesPerSecond()
val rate = deviceHashesPerSecond()
value = formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, rate))
}
@@ -746,6 +746,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">المصدر: %1$s</string>
<string name="nip82_download">تنزيل</string>
<string name="nip82_section_about">حول</string>
@@ -715,6 +715,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">উৎস: %1$s</string>
<string name="nip82_download">ডাউনলোড</string>
<string name="nip82_section_about">সম্পর্কে</string>
@@ -733,6 +733,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Zdroj: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Stáhnout</string>
@@ -717,6 +717,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Quelle: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Herunterladen</string>
@@ -703,6 +703,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Πηγή: %1$s</string>
<string name="nip82_download">Λήψη</string>
<string name="nip82_section_about">Σχετικά</string>
@@ -19,6 +19,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<!-- Call notifications and UI -->
<!-- Settings search -->
<!-- Settings search keywords: an English concept/protocol index that supplements the
@@ -712,6 +712,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fonto: %1$s</string>
<string name="nip82_download">Elŝuti</string>
<string name="nip82_section_about">Pri</string>
@@ -728,6 +728,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fuente: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Descargar</string>
@@ -721,6 +721,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fuente: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Descargar</string>
@@ -721,6 +721,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fuente: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Descargar</string>
@@ -714,6 +714,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">منبع: %1$s</string>
<string name="nip82_download">دانلود</string>
<string name="nip82_section_about">درباره</string>
@@ -705,6 +705,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Lähde: %1$s</string>
<string name="nip82_download">Lataa</string>
<string name="nip82_section_about">Tietoja</string>
@@ -683,6 +683,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Source : %1$s</string>
<string name="nip82_download">Télécharger</string>
<string name="nip82_section_about">À propos</string>
@@ -766,6 +766,17 @@
<string name="napplet_decision_deny">Refuser</string>
<string name="napplet_connected_app_forget">Oublier cette application</string>
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_reason_other">Utiliser ce relais</string>
<string name="relay_auth_reason_my_own_relay">Connectez-vous à votre propre relais</string>
<string name="relay_auth_allow_once">Autoriser une fois</string>
<string name="relay_auth_always_allow">Toujours autoriser ce relais</string>
<string name="relay_auth_always_deliver">Toujours transmettre mes messages</string>
<string name="relay_auth_block">Bloquer ce relais</string>
<string name="relay_auth_forget">Oublier</string>
<string name="relay_auth_last_used">Dernière utilisation il y a %1$s</string>
<string name="relay_auth_no_overrides">Rien ici pour l\'instant — votre politique globale s\'applique à chaque relais.</string>
<string name="relay_auth_decision_allow">Autoriser</string>
<string name="relay_auth_decision_deny">Refuser</string>
<string name="nip82_repository_label">Source : %1$s</string>
@@ -929,8 +940,12 @@
<string name="route_podcasts">Podcasts</string>
<string name="podcast_view_episodes">Voir les épisodes</string>
<string name="podcast_no_episodes">Aucun épisode trouvé pour le moment</string>
<string name="podcast_trailer_season">Saison %1$d</string>
<string name="podcast_explicit">Explicite</string>
<string name="podcast_completed">Terminé</string>
<string name="podcast_premium">Premium</string>
<string name="podcast_support_show">Soutenir la série</string>
<string name="podcast_by_author">par %1$s</string>
<string name="podcast_season">Saison %1$d</string>
<string name="podcast_video">Vidéo</string>
<string name="podcast_transcript">Transcription</string>
@@ -982,6 +997,8 @@
<string name="podcast_show_language_label">Langue</string>
<string name="podcast_show_language_placeholder">en</string>
<string name="podcast_show_copyright_label">Droit d\'auteur</string>
<string name="podcast_trailer_title_placeholder">Titre du trailer</string>
<string name="podcast_trailer_url_label">URL du média</string>
<string name="podcast_your_podcast">Votre podcast</string>
<string name="podcast_untitled">Sans titre</string>
<string name="podcast_no_episodes_yet">Aucun épisode pour le moment. Appuyez sur nouvel épisode pour publier votre premier épisode.</string>
@@ -1669,6 +1686,29 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Groupe</string>
<string name="messages_create_public_private_chat_description">Nouveau Groupe Public ou Privé</string>
<string name="relay_group_create_confirm">Créer</string>
<string name="relay_group_invite_title">Inviter des gens</string>
<string name="relay_group_invite_description">Partager ce code pour que les gens puissent rejoindre ce groupe.</string>
<string name="relay_group_invite_generating">Création d\'une invitation…</string>
<string name="relay_group_invite_copied">Code d\'invitation copié</string>
<string name="relay_group_join_title">Rejoindre le groupe</string>
<string name="relay_group_join_code_label">Code d\'invitation</string>
<string name="relay_group_join_confirm">Rejoindre</string>
<string name="relay_group_role_admin">Admin</string>
<string name="relay_group_role_moderator">Modérateur</string>
<string name="relay_group_role_member">Membre</string>
<string name="relay_group_members_title">Membres</string>
<string name="relay_group_badge_private">Privé</string>
<string name="relay_group_badge_invite_only">Sur invitation seulement</string>
<string name="relay_group_browse_invalid_url">Entrer une URL de relais valide (wss://…).</string>
<string name="relay_group_field_name">Nom</string>
<string name="relay_group_field_about">Description</string>
<string name="relay_group_field_picture">Photo de groupe</string>
<string name="relay_group_add_photo">Ajouter une photo</string>
<string name="relay_group_change_photo">Modifier la photo</string>
<string name="relay_group_section_discovery">Découverte</string>
<string name="relay_group_field_geohash">Localisation (géohash)</string>
<string name="relay_group_flag_private">Privé</string>
<string name="messages_new_message">Privé</string>
<string name="messages_new_message_to">À</string>
<string name="messages_new_message_subject">Sujet</string>
@@ -3161,6 +3201,17 @@
<string name="auto_create_drafts_setting_description">Enregistre automatiquement un brouillon lorsque vous tapez ou quittez un compositeur avec du texte non envoyé, et l\'envoie à vos relais de boîte d\'envoi privée.</string>
<string name="compose_signature_setting_title">Signature</string>
<string name="compose_signature_setting_hint">Votre signature</string>
<plurals name="pow_estimate_hours">
<item quantity="one">%1$d heure</item>
<item quantity="other">%1$d heures</item>
</plurals>
<plurals name="pow_estimate_days">
<item quantity="one">%1$d jour</item>
<item quantity="other">%1$d jours</item>
</plurals>
<string name="pow_time_left_soon">à tout moment maintenant</string>
<string name="pow_publish_failed">Votre %1$s a fini de miner mais n\'a pas pu être publié : %2$s</string>
<string name="pow_kind_report">Signaler</string>
<string name="ai_writing_use_this">Utiliser ceci</string>
<string name="ai_writing_dismiss">Ignorer</string>
<string name="ai_tone_correct">Corriger</string>
+155 -6
View File
@@ -662,9 +662,9 @@
<string name="favorite_app_add">पसंदीदा में जोड़ें</string>
<string name="favorite_app_remove">पसंदीदा से हटाएँ</string>
<string name="favorite_app_open_window">अपनी विंडो में खोलें</string>
<string name="favorite_app_access_title">यह ऐप क्या एक्सेस कर सकता है</string>
<string name="favorite_app_access_static">सैंडबॉक्स में चलता है, आपके खाते तक कोई विशेष पहुँच नहीं।</string>
<string name="favorite_app_access_show">यह क्या एक्सेस कर सकता है</string>
<string name="favorite_app_access_title">इस क्रमक के लिए क्या अभिगम्य है</string>
<string name="favorite_app_access_static">पृथक पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं।</string>
<string name="favorite_app_access_show">इसके लिए क्या अभिगम्य है</string>
<string name="favorite_app_network_tor">Tor पर लोड होता है।</string>
<string name="favorite_app_network_open">खुले वेब पर लोड होता है।</string>
<string name="favorite_app_unavailable">यह ऐप अभी लोड नहीं हुआ है। इसके कार्ड से खोलें, या कुछ देर बाद फिर प्रयास करें।</string>
@@ -783,17 +783,50 @@
<string name="relay_auth_settings_title">पुनःप्रसारक प्रमाणीकरण</string>
<string name="relay_auth_search_keywords">प्रमाण प्रमाणीकरण पुनःप्रसारक हस्ताक्षरण सत्यापन निप॰४२ परिचय</string>
<string name="relay_auth_global_policy">वैश्विक नीति</string>
<string name="relay_auth_custom_section">किसमें प्रवेशांकन करना है</string>
<string name="relay_auth_policy_always">सर्वदा प्रमाणीकरण</string>
<string name="relay_auth_policy_always_desc">प्रमाणीकरण चुनौतियों को हस्ताक्षर प्रत्येक पुनःप्रसारक के लिए जो इसका अनुरोध करता है</string>
<string name="relay_auth_policy_never">प्रमाणीकरण कभी ना करें</string>
<string name="relay_auth_policy_never_desc">प्रमाणीकरण चुनौतियों की उपेक्षा सभी पुनःप्रसारकों के प्रति</string>
<string name="relay_auth_policy_if_in_my_list">केवल मेरे पुनःप्रसारक</string>
<string name="relay_auth_policy_if_in_my_list_desc">केवल उन पुनःप्ररसारकों के साथ प्रमाणीकरण जो आपके पुनःप्रसारक सूची में हैं</string>
<string name="relay_auth_policy_custom">अनुकूलित</string>
<string name="relay_auth_policy_custom_desc">स्पष्ट चयन करें किन पुनःप्रसारकों में प्रवेशांकन करना चाहिए। नीचे जिसको अनुमति नहीं दी गई उन सब के विषय में आप पूछे जाएँगे।</string>
<string name="relay_auth_toggle_my_relays">मेरे पुनःप्रसारक तथा आयोजन स्थल</string>
<string name="relay_auth_toggle_my_relays_desc">प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा सीधे प्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं।</string>
<string name="relay_auth_toggle_read_follows">मेरे द्वारा अनुचरित लोगों के पत्र पढें</string>
<string name="relay_auth_toggle_read_follows_desc">अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें उनके पत्र प्राप्त करने के लिए।</string>
<string name="relay_auth_toggle_message_follows">मेरे द्वारा अनुचरित लोगों को सन्देश भेजें</string>
<string name="relay_auth_toggle_message_follows_desc">अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए।</string>
<string name="relay_auth_toggle_message_strangers">किसी को भी सन्देश भेजें</string>
<string name="relay_auth_toggle_message_strangers_desc">अज्ञात लोगों के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए। मूलविकल्पतः निष्क्रिय। प्रत्येक बार आप से पूछा जाएगा।</string>
<string name="relay_auth_prompt_title">क्या इस पुनःप्रसारक को पुष्टि करें कि यह आप हैं।</string>
<string name="relay_auth_prompt_message">यह पुनःप्रसारक पहले पुष्टि करना चाहता हैं कि यह वस्तुतः आप हैं। इसके परिचालक देखेगा आपका लेखा कौनसा है।</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">क्या आपका सन्देश %1$s को भेजें।</string>
<string name="relay_auth_title_notify">क्या %1$s को सूचित करें।</string>
<string name="relay_auth_title_post_venue">क्या %1$s को पत्र भेजें।</string>
<string name="relay_auth_title_read_venue">क्या %1$s को खोलें।</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">यदि आप पुष्टि नहीं करते तो %1$s को आपका सन्देश नहीं भेजा जाएगा।</string>
<string name="relay_auth_consequence_notify">यदि आप नहीं स्वीकारते तो %1$s को इस विषय पर सूचित नहीं किया जाएगा।</string>
<string name="relay_auth_consequence_post_venue">यदि आप स्वीकारते नहीं तो %1$s को आपका सन्देश प्रकाशित नहीं किया जाएगा।</string>
<string name="relay_auth_consequence_read_venue">यदि आप स्वीकारते नहीं तो आप यहाँ %1$s को नहीं देखेंगे।</string>
<string name="relay_auth_name_and_others">%1$s तथा अन्य</string>
<string name="relay_auth_reason_send_dm">आपका निजी सन्देश भेजें इनको :</string>
<string name="relay_auth_reason_notify_inbox">इनको सूचित करें :</string>
<string name="relay_auth_reason_post_venue">इस शाला को पत्र भेजें</string>
<string name="relay_auth_reason_read_venue">इस शाला को खोलें</string>
<string name="relay_auth_reason_other">इस पुनःप्रसारक का उपयोग करें</string>
<string name="relay_auth_reason_my_own_relay">अपने ही पुनःप्रसारक से संयोजन करें</string>
<string name="relay_auth_allow_once">एक बार अनुमत</string>
<string name="relay_auth_always_allow">इस पुनःप्रसारक को सर्वदा अनुमति दें</string>
<string name="relay_auth_always_deliver">मेरे सन्देशों को सर्वदा भेजें</string>
<string name="relay_auth_block">इस पुनःप्रसारक को बाधित करें</string>
<string name="relay_auth_per_relay_overrides">प्रति पुनःप्रसारक आवरक आदेश</string>
<string name="relay_auth_forget">भूल जाएँ</string>
<string name="relay_auth_last_used">पूर्व प्रयुक्त %1$s पहले</string>
<string name="relay_auth_no_overrides">कोई प्रति पुनःप्रसारक आवरक आदेश नहीं। वैश्विक नीति सर्वत्र लागू</string>
<string name="relay_auth_decision_allow">अनुमत</string>
<string name="relay_auth_decision_deny">अस्वीकार</string>
<string name="relay_auth_remove_override">आवरक आदेश हटाएँ</string>
<string name="nip82_repository_label">स्रोत : %1$s</string>
<string name="nip82_version_label">संस्करण %1$s</string>
<string name="nip82_download">अवरोहण</string>
@@ -1739,6 +1772,110 @@
<string name="messages_create_public_chat">सार्वजनिक</string>
<string name="messages_create_group">समूह</string>
<string name="messages_create_public_private_chat_description">नया निजी अथवा सार्वजनिक झुण्ड</string>
<string name="relay_groups_title">पुनःप्रसारक समूह</string>
<string name="relay_group_view_inline">पंक्ति में</string>
<string name="relay_group_view_grouped">पुनःप्रसारक अनुसार</string>
<string name="relay_group_view_inline_desc">प्रत्येक समूह को अपने ही वार्तालाप के रूप में दर्शाएँ तथा आपके चर्चाओं में मिश्रित।</string>
<string name="relay_group_view_grouped_desc">प्रत्येक पुनःप्रसारक के समूह को एक ही पंक्ति मे संकुचित करें तथा उसके नवीनतम सन्देश पर रखें।</string>
<string name="relay_group_view_mode_title">निप॰२९ समूह दृश्य</string>
<string name="relay_group_server_label">पुनःप्रसारक समूह</string>
<string name="relay_groups_button">समूह</string>
<string name="messages_settings">सन्देश</string>
<string name="relay_group_create_title">समूह बनाएँ</string>
<string name="relay_group_relay_no_nip29">यह पुनःप्रसारक निप॰२९ पुनःप्रसारक समूहों का अवलम्बन करने का विज्ञापन नहीं करता। यहाँ बनाए गए समूह काम नहीं करेंगे। उसका नाम तथा सदस्यों तथा सन्देशों का प्रबन्धन पुनःप्रसारक द्वारा नहीं किया जाएगा। एक पुनःप्रसारक का चयन करें जो पुनःप्रसारक आधारित समूहों का अवलम्बन करता हो।</string>
<string name="relay_group_create_name">समूह नाम</string>
<string name="relay_group_create_topic">विषय (विकल्पात्मक)</string>
<string name="relay_group_create_private">निजी (केवल सदस्य पठन)</string>
<string name="relay_group_create_invite_only">केवल आमन्त्रित</string>
<string name="relay_group_create_confirm">बनाएँ</string>
<string name="relay_group_invite_title">लोगों को आमन्त्रण</string>
<string name="relay_group_invite_description">इस अक्षरराशि को बाँटें जिससे लोग इस समूह से जुड सकें।</string>
<string name="relay_group_invite_generating">आमन्त्रण बनाया जा रहा है…</string>
<string name="relay_group_invite_copied">आमन्त्रण अक्षरराशि अनुकृत</string>
<string name="relay_group_invite_code_label">आमन्त्रण अक्षरराशि (इस केवल आमन्त्रित समूह के लिए):</string>
<string name="relay_group_join_title">समूह से जुडें</string>
<string name="relay_group_join_code_label">आमन्त्रण अक्षरराशि</string>
<string name="relay_group_join_code_hint">यह समूह आमन्त्रण सीमित है। आपको दी गई अक्षरराशि प्रविष्ट करें।</string>
<string name="relay_group_join_confirm">जुडें</string>
<string name="relay_group_pending">अनुरोधित</string>
<string name="relay_group_role_admin">प्रशासक</string>
<string name="relay_group_role_moderator">नियामक</string>
<string name="relay_group_role_member">सदस्य</string>
<string name="relay_group_members_title">सदस्य सूची</string>
<string name="relay_group_make_admin">प्रशासक बनाएँ</string>
<string name="relay_group_make_moderator">नियामक बनाएँ</string>
<string name="relay_group_demote_member">पद हटाएँ</string>
<string name="relay_group_remove_user">समूह से हटाएँ</string>
<string name="relay_group_remove_user_confirm">क्या %1$s को इस समूह से हटाएँ। वे अभिगमन अधिकार खो देंगे पुनः जुडने अथवा आमन्त्रित होने तक।</string>
<string name="relay_group_edit_title">समूह सम्पादन</string>
<string name="relay_group_edit_name">समूह नाम</string>
<string name="relay_group_edit_topic">विषय (विकल्पात्मक)</string>
<string name="relay_group_edit_private">निजी (केवल सदस्य पठन)</string>
<string name="relay_group_edit_invite_only">केवल आमन्त्रित</string>
<string name="relay_group_edit_confirm">अभिलेखन</string>
<string name="relay_group_menu_members">सदस्य सूची</string>
<string name="relay_group_menu_edit">समूह सम्पादन</string>
<string name="relay_group_threads_title">सूत्र</string>
<string name="relay_group_open">समूह खोलें</string>
<string name="relay_group_channels_empty">इस पुनःप्रसारक पर कोई समूह नहीं अब तक।</string>
<string name="relay_group_invite_preparing">आमन्त्रण उत्पादन…</string>
<string name="relay_group_badge_private">निजी</string>
<string name="relay_group_badge_invite_only">केवल आमन्त्रित</string>
<string name="relay_group_browse_invalid_url">प्रविष्ट करें एक मान्य पुनःप्रसारक पता (wss://…)।</string>
<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_add_photo">चित्र जोडें</string>
<string name="relay_group_change_photo">चित्र परिवर्तन</string>
<string name="relay_group_section_discovery">आविष्करण</string>
<string name="relay_group_section_discovery_desc">लोगों का सहयोग करें इस समूह को ढूँढने में। विषयसूची तथा स्थान से अनुकूल आविष्करण छलनियों द्वारा यह प्राप्त होगा (यदि निवास पुनःप्रसारक उनका अवलम्बन करें तो)।</string>
<string name="relay_group_field_topics">विषय सूची</string>
<string name="relay_group_field_topics_hint">द्व्यंकरूप्य नोस्टर कला इत्यादि</string>
<string name="relay_group_field_geohash">स्थान (भूविभेदक)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">अनुमतियाँ</string>
<string name="relay_group_flag_private">निजी</string>
<string name="relay_group_flag_private_desc">केवल सदस्य सन्देशों को पढ सकते हैं।</string>
<string name="relay_group_flag_invite_only">केवल आमन्त्रित</string>
<string name="relay_group_flag_invite_only_desc">लोग आमन्त्रित अथवा अनुमत होने चाहिए जुडने के लिए।</string>
<string name="relay_group_flag_restricted">केवल सदस्यों द्वारा पत्र प्रकाशन</string>
<string name="relay_group_flag_restricted_desc">केवल सदस्य सन्देश भेज सकते हैं।</string>
<string name="relay_group_flag_hidden">सूचिबद्ध नहीं</string>
<string name="relay_group_flag_hidden_desc">इस समूह को छिपाएँ पुनःप्रसारक की सार्वजनिक निर्देशिका से।</string>
<string name="relay_group_discovery_empty">आपके पुनःप्रसारकों में समूहों का खोज…</string>
<string name="relay_group_discovery_empty_filtered">इस छलनी के अनुकूल कोई समूह प्राप्त नहीं अब तक।</string>
<string name="relay_group_favorite_relay">इस पुनःप्रसारक को प्रियचिह्नित करें</string>
<string name="relay_group_threads_empty">कोई सूत्र नहीं अब तक। संकलनचिह्न घुण्डी से एक आरम्भ करें।</string>
<string name="relay_group_thread_new">नया सूत्र</string>
<string name="relay_group_thread_untitled">शीर्षकरहित</string>
<string name="relay_group_thread_title_label">शीर्षक</string>
<string name="relay_group_thread_body_label">कुछ लिखें…</string>
<string name="relay_group_thread_post">पत्र प्रकाशन</string>
<string name="relay_group_browse_title">समूह ढूँढें</string>
<string name="relay_group_browse_description">पुनःप्रसारक में उपलब्ध समूहों का वीक्षण करें। पुनःप्रसारक पता चिपकाएँ अथवा नीचे चयन करें।</string>
<string name="relay_group_browse_relay_label">पुनःप्रसारक पता</string>
<string name="relay_group_browse_go">वीक्षण</string>
<string name="relay_group_browse_your_relays">पुनःप्रसारक जिन पर आप हैं</string>
<string name="relay_group_browse_popular">लोकप्रिय पुनःप्रसारक</string>
<string name="relay_group_no_messages_yet">कोई सन्देश नहीं अब तक</string>
<string name="relay_group_join_to_post">इस समूह से जुडें सन्देशों को भेजने के लिए।</string>
<string name="relay_group_invite_only_to_post">यह समूह आमन्त्रण सीमित है। आपके पास एक आमन्त्रण होना चाहिए पत्र प्रकाशन के लिए।</string>
<plurals name="relay_group_member_count">
<item quantity="one">%1$d सदस्य</item>
<item quantity="other">%1$d सदस्य</item>
</plurals>
<plurals name="relay_group_message_count">
<item quantity="one">%1$d सन्देश</item>
<item quantity="other">%1$d सन्देश</item>
</plurals>
<plurals name="relay_group_message_count_capped">
<item quantity="one">%1$d से अधिक सन्देश</item>
<item quantity="other">%1$d से अधिक सन्देश</item>
</plurals>
<plurals name="relay_group_follows_participating">
<item quantity="one">%1$d व्यक्ति जिसके आप अनुचर</item>
<item quantity="other">%1$d व्यक्ति जिसके आप अनुचर</item>
</plurals>
<string name="messages_new_message">निजी</string>
<string name="messages_new_message_to">के लिए</string>
<string name="messages_new_message_subject">विषय</string>
@@ -2348,6 +2485,7 @@
<string name="profile_ui_setting_app_recommendations">क्रमक अनुशंसाएँ</string>
<string name="profile_ui_setting_zap_received_feed">ज्साप प्राप्त सूचनावली</string>
<string name="profile_ui_setting_followers_feed">अनुचर सूचनावली</string>
<string name="profile_ui_setting_onchain_wallet">द्व्यंकरूप्य (खण्डश्रृंखलाबद्ध) धनकोष</string>
<string name="reactions_settings">प्रतिक्रिया पंक्ति</string>
<string name="reactions_settings_description">समाकृति करें कि किन प्रतिक्रिया घुण्डियाँ दिखाए जाएँगे तथा उनके क्रम तथा यदि संकलन सूचक दिखाए जाएँगे।</string>
<string name="reactions_settings_enabled">सक्षम</string>
@@ -3165,6 +3303,9 @@
<item quantity="other">%1$s सं॰ %2$d अधिक</item>
</plurals>
<!-- PS1 memory-card saves over nostr (kind 38192). "PS1" is the PlayStation 1, do not translate. -->
<string name="ps1_save_title">पीएस१ स्मृति पट्टि अभिलेखन</string>
<string name="ps1_save_block">खण्ड %1$d</string>
<string name="ps1_save_empty_slot">रिक्त छेद</string>
<!-- Roadstr road event reports (kinds 1315/1316). "Roadstr" is a proper noun, do not translate. -->
<string name="road_event_police">रक्षापालिका</string>
<string name="road_event_speed_camera">गति चित्रग्राहक</string>
@@ -3296,6 +3437,14 @@
<string name="compose_signature_setting_title">हस्ताक्षर</string>
<string name="compose_signature_setting_description">सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए।</string>
<string name="compose_signature_setting_hint">आपका हस्ताक्षर</string>
<string name="pow_settings_title">श्रम प्रमाण</string>
<string name="pow_difficulty_title">कठिनाई</string>
<string name="pow_difficulty_explainer">एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीय अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात।</string>
<string name="pow_difficulty_off">निष्क्रिय</string>
<string name="pow_custom_difficulty_title">विशिष्ट कठिनाई</string>
<string name="pow_custom_difficulty_explainer">लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में।</string>
<string name="pow_categories_title">क्या खनन करना चाहिए</string>
<string name="pow_categories_explainer">समयसंकट घटनाओं (पुनःप्रसारक प्रमाणीकरण ज्साप अनुरोध धनकोष तथा हस्ताक्षरकर्ता सन्देश पाण्डुलिपियाँ सूचियाँ) के लिए खनन कभी नहीं।</string>
<string name="ai_writing_use_this">इसका प्रयोग करें</string>
<string name="ai_writing_dismiss">हटाएँ</string>
<string name="ai_tone_correct">सम्यक</string>
+223 -5
View File
@@ -782,18 +782,54 @@
<!-- Relay Authentication (NIP-42) settings -->
<string name="relay_auth_settings_title">Átjátszó-hitelesítés</string>
<string name="relay_auth_search_keywords">hitelesítés, átjátszó, aláírás, ellenőrzés, nip-42, azonosság</string>
<string name="relay_auth_global_policy">Globális szabály</string>
<string name="relay_auth_global_policy">Hitelesítéskor</string>
<string name="relay_auth_custom_section">Mibe jelentkezzen be</string>
<string name="relay_auth_policy_always">Hitelesítés kérése mindig</string>
<string name="relay_auth_policy_always_desc">Minden olyan átjátszó esetében alá kell írni az azonosítási kihívásokat, amely ezt kéri</string>
<string name="relay_auth_policy_never">Soha ne végezzen hitelesítést</string>
<string name="relay_auth_policy_never_desc">Az összes átjátszó hitelesítési kérésének figyelmen kívül hagyása</string>
<string name="relay_auth_policy_if_in_my_list">Csak saját átjátszók</string>
<string name="relay_auth_policy_if_in_my_list_desc">Csak az átjátszólistában szereplő átjászókkal hitelesítse magát</string>
<string name="relay_auth_policy_custom">Egyéni</string>
<string name="relay_auth_policy_custom_desc">Válassza ki pontosan, hogy mely átjátszókba jelentkezik be. A lent nem engedélyezett dolgokról kérdést fog kapni.</string>
<string name="relay_auth_toggle_my_relays">Saját átjátszók és helyszínek</string>
<string name="relay_auth_toggle_my_relays_desc">Bejelentkezés a saját átjátszóiba, valamint azokba a nyilvános csevegésekbe, közösségekbe és élő közvetítésekbe, amelyekhez csatlakozott vagy amelyeket kedvelt.</string>
<string name="relay_auth_toggle_read_follows">Követett személyek bejegyzéseinek olvasása</string>
<string name="relay_auth_toggle_read_follows_desc">Bejelentkezés egy követett személy átjátszóiba a bejegyzéseinek letöltéséhez.</string>
<string name="relay_auth_toggle_message_follows">Üzenet küldése a követett személyeknek</string>
<string name="relay_auth_toggle_message_follows_desc">Bejelentkezés egy követett személy átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez.</string>
<string name="relay_auth_toggle_message_strangers">Üzenet küldése bárkinek</string>
<string name="relay_auth_toggle_message_strangers_desc">Bejelentkezés idegenek átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez. Alapértelmezés szerint ki van kapcsolva, ezért minden alkalommal kérdést fog kapni.</string>
<string name="relay_auth_prompt_title">Megerősíti a személyazonosságát ennek az átjátszónak?</string>
<string name="relay_auth_prompt_message">Ez az átjátszó először meg szeretné erősíteni, hogy tényleg Ön az. Az üzemeltetője látni fogja, hogy melyik fiókot használja.</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">Elküldi az üzenetet neki: %1$s?</string>
<string name="relay_auth_title_notify">Értesíti őt: %1$s?</string>
<string name="relay_auth_title_read">Betölti a bejegyzéseket tőle: %1$s?</string>
<string name="relay_auth_title_post_venue">Közzéteszi itt: %1$s?</string>
<string name="relay_auth_title_read_venue">Megnyitja ezt: %1$s?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">Ha nem teszi meg, az üzenete nem lesz kézbesítve neki: %1$s.</string>
<string name="relay_auth_consequence_notify">Ha nem teszi meg, %1$s nem kap erről értesítést.</string>
<string name="relay_auth_consequence_read">Ha nem teszi meg, nem fogja itt látni a bejegyzéseket tőle: %1$s.</string>
<string name="relay_auth_consequence_post_venue">Ha nem teszi meg, az üzenete nem lesz közzétéve itt: %1$s.</string>
<string name="relay_auth_consequence_read_venue">Ha nem teszi meg, nem fogja itt látni ezt: %1$s.</string>
<string name="relay_auth_name_and_others">%1$s és mások</string>
<string name="relay_auth_reason_send_dm">Privát üzenet küldése neki:</string>
<string name="relay_auth_reason_notify_inbox">Értesítendő:</string>
<string name="relay_auth_reason_read_outbox">Bejegyzések letöltése tőle:</string>
<string name="relay_auth_reason_post_venue">Közzététel ebben a szobában</string>
<string name="relay_auth_reason_read_venue">Szoba megnyitása</string>
<string name="relay_auth_reason_other">Átjátszó használata</string>
<string name="relay_auth_reason_my_own_relay">Kapcsolódás a saját átjátszójához</string>
<string name="relay_auth_allow_once">Engedélyezés egyszer</string>
<string name="relay_auth_always_allow">Mindig engedélyezze ezt az átjátszót</string>
<string name="relay_auth_always_deliver">Mindig kézbesítse az üzeneteimet</string>
<string name="relay_auth_block">Átjátszó letiltása</string>
<string name="relay_auth_per_relay_overrides">Átjátszónkénti felülírások</string>
<string name="relay_auth_no_overrides">Nincs átjátszónkénti felülírás - a globális szabály mindenhol érvényes</string>
<string name="relay_auth_forget">Elfelejtés</string>
<string name="relay_auth_last_used">Utoljára használva: %1$s</string>
<string name="relay_auth_no_overrides">Itt még nincs semmi. A globális szabályzat minden átjátszóra vonatkozik.</string>
<string name="relay_auth_decision_allow">Engedélyezés</string>
<string name="relay_auth_decision_deny">Megtagadás</string>
<string name="relay_auth_remove_override">Felülírás eltávolítása</string>
<string name="nip82_repository_label">Forrás: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Letöltés</string>
@@ -1739,6 +1775,110 @@
<string name="messages_create_public_chat">Nyilvános</string>
<string name="messages_create_group">Csoport</string>
<string name="messages_create_public_private_chat_description">Új nyilvános vagy privát csoport</string>
<string name="relay_groups_title">Átjátszócsoportok</string>
<string name="relay_group_view_inline">Beágyazott</string>
<string name="relay_group_view_grouped">Átjátszó szerint</string>
<string name="relay_group_view_inline_desc">Jelenítsen meg minden csoportot saját beszélgetésként, a csevegései közé keverve.</string>
<string name="relay_group_view_grouped_desc">Átjátszók csoportjainak összecsukása egyetlen sorba, a legújabb üzenetnél elhelyezve.</string>
<string name="relay_group_view_mode_title">NIP-29 csoport megjelenítése</string>
<string name="relay_group_server_label">Átjátszócsoportok</string>
<string name="relay_groups_button">Csoportok</string>
<string name="messages_settings">Üzenetek</string>
<string name="relay_group_create_title">Csoport létrehozása</string>
<string name="relay_group_relay_no_nip29">Ez az átjátszó nem hirdeti a NIP-29 átjátszócsoportok támogatását. Az itt létrehozott csoport nem fog működni. A nevét, a tagjait és az üzeneteit nem az átjátszó fogja kezelni. Válasszon egy olyan átjátszót, amely támogatja az átjátszóalapú csoportokat.</string>
<string name="relay_group_create_name">Csoport neve</string>
<string name="relay_group_create_topic">Téma (nem kötelező)</string>
<string name="relay_group_create_private">Privát (csak a tagok olvashatják)</string>
<string name="relay_group_create_invite_only">Csak meghívóval</string>
<string name="relay_group_create_confirm">Létrehozás</string>
<string name="relay_group_invite_title">Emberek meghívása</string>
<string name="relay_group_invite_description">Ossza meg ezt a kódot, hogy mások is csatlakozhassanak ehhez a csoporthoz.</string>
<string name="relay_group_invite_generating">Meghívó létrehozása…</string>
<string name="relay_group_invite_copied">Meghívókód másolva</string>
<string name="relay_group_invite_code_label">Meghívókód (ehhez a csak meghívóval elérhető csoporthoz):</string>
<string name="relay_group_join_title">Csatlakozás a csoporthoz</string>
<string name="relay_group_join_code_label">Meghívókód</string>
<string name="relay_group_join_code_hint">Ez a csoport csak meghívóval érhető el. Adja meg a kapott kódot.</string>
<string name="relay_group_join_confirm">Csatlakozás</string>
<string name="relay_group_pending">Kérve</string>
<string name="relay_group_role_admin">Adminisztrátor</string>
<string name="relay_group_role_moderator">Moderátor</string>
<string name="relay_group_role_member">Tag</string>
<string name="relay_group_members_title">Tagok</string>
<string name="relay_group_make_admin">Kinevezés adminisztrátorrá</string>
<string name="relay_group_make_moderator">Kinevezés moderátorrá</string>
<string name="relay_group_demote_member">Szerepkör eltávolítása</string>
<string name="relay_group_remove_user">Eltávolítás a csoportból</string>
<string name="relay_group_remove_user_confirm">Eltávolítja őt: %1$s, ebből a csoportból? Elveszíti a hozzáférést, amíg újra hozzá nem adják vagy meg nem hívják.</string>
<string name="relay_group_edit_title">Csoport szerkesztése</string>
<string name="relay_group_edit_name">Csoport neve</string>
<string name="relay_group_edit_topic">Téma (nem kötelező)</string>
<string name="relay_group_edit_private">Privát (csak a tagok olvashatják)</string>
<string name="relay_group_edit_invite_only">Csak meghívóval</string>
<string name="relay_group_edit_confirm">Mentés</string>
<string name="relay_group_menu_members">Tagok</string>
<string name="relay_group_menu_edit">Csoport szerkesztése</string>
<string name="relay_group_threads_title">Szálak</string>
<string name="relay_group_open">Csoport megnyitása</string>
<string name="relay_group_channels_empty">Még nincsenek csoportok ezen az átjátszón.</string>
<string name="relay_group_invite_preparing">Meghívó előkészítése…</string>
<string name="relay_group_badge_private">Privát</string>
<string name="relay_group_badge_invite_only">Csak meghívóval</string>
<string name="relay_group_browse_invalid_url">Adjon meg egy érvényes átjátszó-webcímet (wss://…).</string>
<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_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>
<string name="relay_group_section_discovery_desc">Segítsen másoknak is megtalálni ezt a csoportot. A témák és a helyszín megjelenítik a megfelelő felfedezési szűrők alatt (ha a kiszolgáló átjátszó támogatja azokat).</string>
<string name="relay_group_field_topics">Témák</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, művészet</string>
<string name="relay_group_field_geohash">Helyszín (geo-kivonat)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">Engedélyek</string>
<string name="relay_group_flag_private">Privát</string>
<string name="relay_group_flag_private_desc">Csak a tagok olvashatják az üzeneteket.</string>
<string name="relay_group_flag_invite_only">Csak meghívóval</string>
<string name="relay_group_flag_invite_only_desc">Az embereket meg kell hívni vagy jóvá kell hagyni a csatlakozáshoz.</string>
<string name="relay_group_flag_restricted">Csak tagok posztolhatnak</string>
<string name="relay_group_flag_restricted_desc">Csak a tagok tehetnek közzé üzeneteket.</string>
<string name="relay_group_flag_hidden">Nem listázott</string>
<string name="relay_group_flag_hidden_desc">Csoport elrejtése az átjátszó nyilvános könyvtárából.</string>
<string name="relay_group_discovery_empty">Csoportok keresése az átjátszók között…</string>
<string name="relay_group_discovery_empty_filtered">Még nem találhatók csoportok ehhez a szűrőhöz.</string>
<string name="relay_group_favorite_relay">Átjátszó hozzáadása a kedvencekhez</string>
<string name="relay_group_threads_empty">Még nincsenek szálak. Indítson egyet a + gombbal.</string>
<string name="relay_group_thread_new">Új szál</string>
<string name="relay_group_thread_untitled">Névtelen</string>
<string name="relay_group_thread_title_label">Cím</string>
<string name="relay_group_thread_body_label">Írjon valamit…</string>
<string name="relay_group_thread_post">Közzététel</string>
<string name="relay_group_browse_title">Csoportok keresése</string>
<string name="relay_group_browse_description">Böngésszen az egy átjátszón tárolt csoportok között. Illesszen be egy átjátszócímet, vagy válasszon egyet alább.</string>
<string name="relay_group_browse_relay_label">Átjátszó címe</string>
<string name="relay_group_browse_go">Böngészés</string>
<string name="relay_group_browse_your_relays">Átjátszók, amelyeken jelen van</string>
<string name="relay_group_browse_popular">Népszerű átjátszók</string>
<string name="relay_group_no_messages_yet">Még nincsenek üzenetek</string>
<string name="relay_group_join_to_post">Csatlakozzon ehhez a csoporthoz az üzenetek küldéséhez.</string>
<string name="relay_group_invite_only_to_post">Ez a csoport csak meghívóval érhető el. Meghívóra van szüksége a közzétételhez.</string>
<plurals name="relay_group_member_count">
<item quantity="one">%1$d tag</item>
<item quantity="other">%1$d tag</item>
</plurals>
<plurals name="relay_group_message_count">
<item quantity="one">%1$d üzenet</item>
<item quantity="other">%1$d üzenet</item>
</plurals>
<plurals name="relay_group_message_count_capped">
<item quantity="one">%1$d+ üzenet</item>
<item quantity="other">%1$d+ üzenet</item>
</plurals>
<plurals name="relay_group_follows_participating">
<item quantity="one">%1$d követett személy</item>
<item quantity="other">%1$d Ön által követett személy</item>
</plurals>
<string name="messages_new_message">Privát</string>
<string name="messages_new_message_to">Neki:</string>
<string name="messages_new_message_subject">Tárgy</string>
@@ -3300,6 +3440,84 @@
<string name="compose_signature_setting_title">Aláírás</string>
<string name="compose_signature_setting_description">Az üzenet végéhez lesz hozzáadva új bejegyzés, válasz, idézet vagy cikk írásakor. A letiltáshoz hagyja üresen.</string>
<string name="compose_signature_setting_hint">Saját alírás</string>
<string name="pow_settings_title">Munkabizonyítás</string>
<string name="pow_difficulty_title">Nehézségi szint</string>
<string name="pow_difficulty_explainer">A közzététel előtt egy NIP-13 „munkabizonyítást” bányász a bejegyzéseibe, így az átjátszók és az olvasók mérlegelhetik azokat a kéretlen tartalommal szemben. A magasabb értékek bányászata exponenciálisan több időt vesz igénybe. A bejegyzések a bányászat után a háttérben lesznek közzétéve.</string>
<string name="pow_difficulty_off">Kikapcsolva</string>
<string name="pow_custom_difficulty_title">Egyéni nehézségi szint</string>
<string name="pow_custom_difficulty_explainer">A cél finomhangolása vezető nulla bitekben.</string>
<string name="pow_categories_title">Mit bányásszon</string>
<string name="pow_categories_explainer">Az időkritikus események (átjátszóhitelesítés, zapkérések, pénztárca és aláíróüzenetek, piszkozatok, listák) soha nincsenek bányászva.</string>
<string name="pow_category_short_notes">Rövid jegyzetek és válaszok</string>
<string name="pow_category_short_notes_explainer">Az elsődleges nyilvános kéretlen tartalmi felület</string>
<string name="pow_category_comments">Megjegyzések</string>
<string name="pow_category_comments_explainer">Válaszok cikkekre, fájlokra és egyéb tartalmakra</string>
<string name="pow_category_reports">Jelentések</string>
<string name="pow_category_reports_explainer">Az átjátszók a költségük alapján mérlegelik a jelentéseket</string>
<string name="pow_category_long_form">Hosszú formátum és kiemelések</string>
<string name="pow_category_long_form_explainer">Cikkek és kiemelések; ritka, a költség elhanyagolható</string>
<string name="pow_category_voice">Hangüzenetek</string>
<string name="pow_category_voice_explainer">Nyilvános, hangalapú bejegyzések és válaszok</string>
<string name="pow_category_reposts">Újraközzétevések</string>
<string name="pow_category_reposts_explainer">Nagyszámú aktív felhasználónál</string>
<string name="pow_category_reactions">Reakciók</string>
<string name="pow_category_reactions_explainer">A legnagyobb mennyiségű típus; minden kedvelés bányászata meríti az akkumulátort</string>
<string name="pow_category_public_chat">Nyilvános és élő csevegés</string>
<string name="pow_category_public_chat_explainer">A bányászati késedelmek rontják a beszélgetés menetét</string>
<string name="pow_category_gift_wraps">Privát üzenet burkolók</string>
<string name="pow_category_gift_wraps_explainer">Lehetővé teszi a kéretlen tartalmak szűrését a beérkező közvetlen üzenetekben az átjátszók számára; csak a külső réteget bányássza, az üzenetet soha</string>
<string name="pow_category_other_public">Egyéb nyilvános tartalom</string>
<string name="pow_category_other_public_explainer">Szavazások, élő állapotok, apróhirdetések és minden egyéb nyilvános dolog</string>
<string name="pow_mining_title">„Munkabizonyítás” bányászata</string>
<plurals name="pow_mining_progress">
<item quantity="one">„Munkabizonyítás” bányászata… (%1$d bejegyzés a sorban)</item>
<item quantity="other">Proof of work bányászata… (%1$d bejegyzés a sorban)</item>
</plurals>
<plurals name="pow_mining_job">
<item quantity="one">%1$s • bányászat %2$d biten</item>
<item quantity="other">%1$s • bányászat %2$d biten</item>
</plurals>
<plurals name="pow_queued_job">
<item quantity="one">%1$s • várakozás a bányászatra %2$d biten</item>
<item quantity="other">%1$s • várakozás a bányászatra %2$d biten</item>
</plurals>
<plurals name="pow_option_default_on">
<item quantity="one">Alapértelmezés (%1$d bit)</item>
<item quantity="other">Alapértelmezés (%1$d bit)</item>
</plurals>
<string name="pow_option_default_off">Alapértelmezés (kikapcsolva)</string>
<string name="pow_option_off">Kikapcsolva ehhez a bejegyzéshez</string>
<plurals name="pow_option_bits">
<item quantity="one">%1$d bit</item>
<item quantity="other">%1$d bit</item>
</plurals>
<string name="pow_notification_channel_name">„Munkabizonyítás” bányászata</string>
<string name="pow_notification_channel_description">Azokat a bejegyzéseket jeleníti meg, amelyek még mindig a NIP-13 szerinti munkabizonyítást végzik, így azok befejeződhetnek, miután kilép az alkalmazásból.</string>
<string name="pow_notification_cancel_all">Mégse</string>
<string name="pow_difficulty_estimate">≈ %1$s bejegyzésenként ezen az eszközön</string>
<string name="pow_estimate_instant">&lt;1 mp</string>
<plurals name="pow_estimate_seconds">
<item quantity="one">%1$d mp</item>
<item quantity="other">%1$d mp</item>
</plurals>
<plurals name="pow_estimate_minutes">
<item quantity="one">%1$d perc</item>
<item quantity="other">%1$d perc</item>
</plurals>
<plurals name="pow_estimate_hours">
<item quantity="one">%1$d óra</item>
<item quantity="other">%1$d óra</item>
</plurals>
<plurals name="pow_estimate_days">
<item quantity="one">%1$d nap</item>
<item quantity="other">%1$d nap</item>
</plurals>
<string name="pow_time_left">≈ %1$s van hátra</string>
<string name="pow_time_left_soon">mostmár bármelyik pillanatban</string>
<string name="pow_publish_failed">A(z) %1$s bányászata befejeződött, de nem sikerült közzétenni: %2$s</string>
<string name="pow_publish_failed_retry">A(z) %1$s bányászata befejeződött, de nem sikerült közzétenni. A következő indításkor az alkalmazás újra megpróbálja.</string>
<string name="pow_kind_chat_message">Csevegési üzenet</string>
<string name="pow_kind_report">Jelentés</string>
<string name="ai_writing_use_this">Ennek használata</string>
<string name="ai_writing_dismiss">Eltüntetés</string>
<string name="ai_tone_correct">Javítás</string>
@@ -690,6 +690,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Sumber: %1$s</string>
<string name="nip82_download">Unduh</string>
<string name="nip82_section_about">Tentang</string>
@@ -695,6 +695,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fonte: %1$s</string>
<string name="nip82_download">Scarica</string>
<string name="nip82_section_about">Informazioni</string>
@@ -702,6 +702,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">ソース: %1$s</string>
<string name="nip82_download">ダウンロード</string>
<string name="nip82_section_about">概要</string>
@@ -699,6 +699,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">출처: %1$s</string>
<string name="nip82_download">다운로드</string>
<string name="nip82_section_about">소개</string>
@@ -717,6 +717,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Avots: %1$s</string>
<string name="nip82_download">Lejupielādēt</string>
<string name="nip82_section_about">Par</string>
@@ -774,13 +774,12 @@
<string name="relay_auth_policy_always_desc">Auth-challenges ondertekenen voor elke relay die erom vraagt</string>
<string name="relay_auth_policy_never">Nooit authenticeren</string>
<string name="relay_auth_policy_never_desc">Auth-challenges van alle relays negeren</string>
<string name="relay_auth_policy_if_in_my_list">Alleen mijn relays</string>
<string name="relay_auth_policy_if_in_my_list_desc">Alleen authenticeren met relays in je relaylijst</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_per_relay_overrides">Per-relay-uitzonderingen</string>
<string name="relay_auth_no_overrides">Geen per-relay-uitzonderingen — globaal beleid geldt overal</string>
<string name="relay_auth_decision_allow">Toestaan</string>
<string name="relay_auth_decision_deny">Weigeren</string>
<string name="relay_auth_remove_override">Uitzondering verwijderen</string>
<string name="nip82_repository_label">Bron: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Downloaden</string>
+246 -3
View File
@@ -799,17 +799,53 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_auth_settings_title">Uwierzytelnianie transmitera</string>
<string name="relay_auth_search_keywords">auth uwierzytelnianie transmiter podpis weryfikacja nip-42 tożsamość</string>
<string name="relay_auth_global_policy">Polityka globalna</string>
<string name="relay_auth_custom_section">Jak się zalogować</string>
<string name="relay_auth_policy_always">Zawsze uwierzytelniaj</string>
<string name="relay_auth_policy_always_desc">Poświadcz autoryzację dla każdego transmitera, który o to poprosi</string>
<string name="relay_auth_policy_never">Nigdy nie uwierzytelniaj</string>
<string name="relay_auth_policy_never_desc">Ignoruj żądania uwierzytelnienia ze wszystkich transmiterów</string>
<string name="relay_auth_policy_if_in_my_list">Tylko moje transmitery</string>
<string name="relay_auth_policy_if_in_my_list_desc">Uwierzytelniaj tylko z transmiterami z listy transmiterów</string>
<string name="relay_auth_policy_custom">Niestandardowa</string>
<string name="relay_auth_policy_custom_desc">Wybierz dokładnie, do których transmiterów chcesz się zalogować. Zostaniesz zapytany o wszystko, na co nie wyraziłeś zgody poniżej.</string>
<string name="relay_auth_toggle_my_relays">Moje transmitery i obiekty</string>
<string name="relay_auth_toggle_my_relays_desc">Zaloguj się do własnych transmiterów oraz do publicznych czatów, społeczności i transmisji na żywo, do których dołączyłeś lub które dodałeś do ulubionych.</string>
<string name="relay_auth_toggle_read_follows">Czytaj posty od osób, które obserwuję</string>
<string name="relay_auth_toggle_read_follows_desc">Zaloguj się na transmitery obserwowanych, aby pobrać ich posty.</string>
<string name="relay_auth_toggle_message_follows">Wyślij wiadomość do obserwowanych osób</string>
<string name="relay_auth_toggle_message_follows_desc">Zaloguj się na transmitery obserwowanych, aby wysyłać prywatne wiadomości, odpowiedzi i powiadomienia.</string>
<string name="relay_auth_toggle_message_strangers">Wiadomość do wszystkich</string>
<string name="relay_auth_toggle_message_strangers_desc">Zaloguj się do transmiterów nieznanych użytkowników, aby wysyłać prywatne wiadomości, odpowiedzi i powiadomienia. Opcja domyślnie wyłączona; za każdym razem pojawi się pytanie o zgodę.</string>
<string name="relay_auth_prompt_title">Czy chcesz potwierdzić, że to właśnie ty łączysz się z tym transmiterem?</string>
<string name="relay_auth_prompt_message">Ten transmiter chce najpierw potwierdzić, że to naprawdę ty. Jego operator sprawdzi, z jakiego konta się logujesz.</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">Wysłać wiadomość do %1$s?</string>
<string name="relay_auth_title_notify">Powiadom %1$s?</string>
<string name="relay_auth_title_read">Wczytaj posty z %1$s?</string>
<string name="relay_auth_title_post_venue">Opublikować do %1$s?</string>
<string name="relay_auth_title_read_venue">Otworzyć %1$s?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">Jeśli nie, Twoja wiadomość do %1$s nie zostanie dostarczona.</string>
<string name="relay_auth_consequence_notify">Jeśli nie, %1$s nie zostanie o tym powiadomiony.</string>
<string name="relay_auth_consequence_read">Jeśli nie, nie zobaczysz tutaj postów z %1$s.</string>
<string name="relay_auth_consequence_post_venue">Jeśli nie, Twoja wiadomość do %1$s nie zostanie wysłana.</string>
<string name="relay_auth_consequence_read_venue">Jeśli nie, nie zobaczysz tutaj %1$s.</string>
<string name="relay_auth_name_and_others">%1$s i inni</string>
<string name="relay_auth_reason_send_dm">Wyślij prywatną wiadomość do:</string>
<string name="relay_auth_reason_notify_inbox">Powiadom:</string>
<string name="relay_auth_reason_read_outbox">Pobierz posty z:</string>
<string name="relay_auth_reason_post_venue">Opublikuj w tym pokoju</string>
<string name="relay_auth_reason_read_venue">Otwórz ten pokój</string>
<string name="relay_auth_reason_other">Użyj tego transmitera</string>
<string name="relay_auth_reason_my_own_relay">Podłącz się do własnego transmitera</string>
<string name="relay_auth_allow_once">Zezwól jednorazowo</string>
<string name="relay_auth_always_allow">Zawsze zezwalaj na dostęp do tego transmitera</string>
<string name="relay_auth_always_deliver">Zawsze przekazuj moje wiadomości</string>
<string name="relay_auth_block">Zablokuj ten transmiter</string>
<string name="relay_auth_per_relay_overrides">Nadrzędność dla poszczególnych transmiterów</string>
<string name="relay_auth_forget">Zapomnij</string>
<string name="relay_auth_last_used">Ostatnio użyty %1$s temu</string>
<string name="relay_auth_no_overrides">Brak nadrzędnych reguł dla transmiterów — polityka globalna obowiązuje wszędzie</string>
<string name="relay_auth_decision_allow">Zezwól</string>
<string name="relay_auth_decision_deny">Odmów</string>
<string name="relay_auth_remove_override">Usuń nadpisanie</string>
<string name="nip82_repository_label">Źródło: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Pobierz</string>
@@ -1773,6 +1809,118 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="messages_create_public_chat">Publiczna</string>
<string name="messages_create_group">Grupa</string>
<string name="messages_create_public_private_chat_description">Nowa Grupa Publiczna lub Prywatna</string>
<string name="relay_groups_title">Grupy Transmiterów</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">Wg transmitera</string>
<string name="relay_group_view_inline_desc">Wyświetlaj każdą grupę jako osobną rozmowę, umieszczoną wśród Twoich czatów.</string>
<string name="relay_group_view_grouped_desc">Zwiń grupy poszczególnych transmiterów do jednego wiersza, umieszczając je przy najnowszej wiadomości.</string>
<string name="relay_group_view_mode_title">Wyświetlanie grupy NIP-29</string>
<string name="relay_group_server_label">Grupy Transmiterów</string>
<string name="relay_groups_button">Grupy</string>
<string name="messages_settings">Wiadomości</string>
<string name="relay_group_create_title">Utwórz grupę</string>
<string name="relay_group_relay_no_nip29">Ten transmiter nie obsługuje grup zgodnych ze standardem NIP-29. Grupa utworzona tutaj nie będzie działać — transmiter nie będzie zarządzał jej nazwą, członkami ani wiadomościami. Wybierz transmiter, który obsługuje grupy oparte na rekomendacjach.</string>
<string name="relay_group_create_name">Nazwa grupy</string>
<string name="relay_group_create_topic">Temat (opcjonalnie)</string>
<string name="relay_group_create_private">Prywatna (odczyt tylko dla członków)</string>
<string name="relay_group_create_invite_only">Tylko zaproszenie</string>
<string name="relay_group_create_confirm">Utwórz</string>
<string name="relay_group_invite_title">Zaproś osoby</string>
<string name="relay_group_invite_description">Udostępnij ten kod, aby użytkownicy mogli dołączyć do tej grupy.</string>
<string name="relay_group_invite_generating">Tworzenie zaproszenia…</string>
<string name="relay_group_invite_copied">Kod zaproszenia skopiowany</string>
<string name="relay_group_invite_code_label">Kod zaproszenia (dla tej grupy tylko zaproszenia):</string>
<string name="relay_group_join_title">Dołącz do grupy</string>
<string name="relay_group_join_code_label">Kod zaproszenia</string>
<string name="relay_group_join_code_hint">Ta grupa jest dostępna wyłącznie z zaproszeniem. Wpisz otrzymany kod.</string>
<string name="relay_group_join_confirm">Dołącz</string>
<string name="relay_group_pending">Wysłano prośbę</string>
<string name="relay_group_role_admin">Admin</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_role_member">Członek</string>
<string name="relay_group_members_title">Członkowie</string>
<string name="relay_group_make_admin">Stwórz administratora</string>
<string name="relay_group_make_moderator">Stwórz moderatora</string>
<string name="relay_group_demote_member">Usuń rolę</string>
<string name="relay_group_remove_user">Usuń z grupy</string>
<string name="relay_group_remove_user_confirm">Usunąć %1$s z tej grupy? Stracą dostęp do czasu ponownego dodania lub ponownego zaproszenia.</string>
<string name="relay_group_edit_title">Edytuj grupę</string>
<string name="relay_group_edit_name">Nazwa grupy</string>
<string name="relay_group_edit_topic">Temat (opcjonalnie)</string>
<string name="relay_group_edit_private">Prywatna (odczyt tylko dla członków)</string>
<string name="relay_group_edit_invite_only">Tylko na zaproszenie</string>
<string name="relay_group_edit_confirm">Zapisz</string>
<string name="relay_group_menu_members">Członkowie</string>
<string name="relay_group_menu_edit">Edytuj grupę</string>
<string name="relay_group_threads_title">Wątki</string>
<string name="relay_group_open">Grupa otwarta</string>
<string name="relay_group_channels_empty">Brak grup na tym przekaźniku transmiterze.</string>
<string name="relay_group_invite_preparing">Przygotowywanie zaproszenia…</string>
<string name="relay_group_badge_private">Prywatna</string>
<string name="relay_group_badge_invite_only">Tylko na zaproszenie</string>
<string name="relay_group_browse_invalid_url">Wprowadź poprawny adres URL transmitera (wss://…).</string>
<string name="relay_group_field_name">Nazwa</string>
<string name="relay_group_field_about">Opis</string>
<string name="relay_group_field_picture">Zdjęcie grupy</string>
<string name="relay_group_add_photo">Dodaj zdjęcie</string>
<string name="relay_group_change_photo">Zmień zdjęcie</string>
<string name="relay_group_section_discovery">Odkryj</string>
<string name="relay_group_section_discovery_desc">Pomóż innym znaleźć tę grupę. Tematy i lokalizacja sprawiają, że grupa pojawia się w wynikach wyszukiwania przy użyciu odpowiednich filtrów (o ile transmiter hosta je obsługuje).</string>
<string name="relay_group_field_topics">Tematy</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, sztuka</string>
<string name="relay_group_field_geohash">Lokalizacja (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">Uprawnienia</string>
<string name="relay_group_flag_private">Prywatna</string>
<string name="relay_group_flag_private_desc">Tylko członkowie mogą czytać wiadomości.</string>
<string name="relay_group_flag_invite_only">Tylko na zaproszenie</string>
<string name="relay_group_flag_invite_only_desc">Aby dołączyć, trzeba otrzymać zaproszenie lub zostać zatwierdzonym.</string>
<string name="relay_group_flag_restricted">Publikowanie dostępne tylko dla członków</string>
<string name="relay_group_flag_restricted_desc">Tylko członkowie mogą wysyłać wiadomości.</string>
<string name="relay_group_flag_hidden">Niewymieniona</string>
<string name="relay_group_flag_hidden_desc">Ukryj tę grupę w publicznym katalogu transmitera.</string>
<string name="relay_group_discovery_empty">Szukam grup w Twoich transmiterach…</string>
<string name="relay_group_discovery_empty_filtered">Nie znaleziono jeszcze żadnych grup dla tego filtra.</string>
<string name="relay_group_favorite_relay">Faworyzuj ten transmiter</string>
<string name="relay_group_threads_empty">Brak wątków. Zacznij od przycisku +.</string>
<string name="relay_group_thread_new">Nowy wątek</string>
<string name="relay_group_thread_untitled">Bez tytułu</string>
<string name="relay_group_thread_title_label">Tytuł</string>
<string name="relay_group_thread_body_label">Napisz coś…</string>
<string name="relay_group_thread_post">Post</string>
<string name="relay_group_browse_title">Znajdź grupy</string>
<string name="relay_group_browse_description">Przeglądaj grupy hostowane na transmiterze. Wklej adres transmitera lub wybierz jeden z poniższych.</string>
<string name="relay_group_browse_relay_label">Adres transmitera</string>
<string name="relay_group_browse_go">Wybierz</string>
<string name="relay_group_browse_your_relays">Transmitery, których używasz</string>
<string name="relay_group_browse_popular">Popularne transmitery</string>
<string name="relay_group_no_messages_yet">Brak wiadomości</string>
<string name="relay_group_join_to_post">Dołącz do tej grupy, aby wysyłać wiadomości.</string>
<string name="relay_group_invite_only_to_post">Ta grupa jest dostępna wyłącznie dla zaproszonych osób — aby móc publikować posty, potrzebujesz zaproszenia.</string>
<plurals name="relay_group_member_count">
<item quantity="one">%1$d członek</item>
<item quantity="few">%1$d członków</item>
<item quantity="many">%1$d członków</item>
<item quantity="other">%1$d członkowie</item>
</plurals>
<plurals name="relay_group_message_count">
<item quantity="one">%1$d wiadomość</item>
<item quantity="few">%1$d wiadomości</item>
<item quantity="many">%1$d wiadomości</item>
<item quantity="other">%1$d wiadomości</item>
</plurals>
<plurals name="relay_group_message_count_capped">
<item quantity="one">%1$d+ wiadomość</item>
<item quantity="few">%1$d+ wiadomości</item>
<item quantity="many">%1$d+ wiadomości</item>
<item quantity="other">%1$d+ wiadomości</item>
</plurals>
<plurals name="relay_group_follows_participating">
<item quantity="one">%1$d osoba, którą obserwujesz</item>
<item quantity="few">%1$d osób, które obserwujesz</item>
<item quantity="many">%1$d osób, które obserwujesz</item>
<item quantity="other">%1$d osoby, które obserwujesz</item>
</plurals>
<string name="messages_new_message">Prywatna</string>
<string name="messages_new_message_to">Do</string>
<string name="messages_new_message_subject">Temat</string>
@@ -3381,6 +3529,101 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="compose_signature_setting_title">Podpis</string>
<string name="compose_signature_setting_description">Dodawane na końcu wiadomości podczas tworzenia nowego wpisu, odpowiedzi, cytatu lub artykułu. Aby wyłączyć tę funkcję, pozostaw to pole puste.</string>
<string name="compose_signature_setting_hint">Twój podpis</string>
<string name="pow_settings_title">Dowód wykonania</string>
<string name="pow_difficulty_title">Stopień trudności</string>
<string name="pow_difficulty_explainer">Przed opublikowaniem postów generuje się w nich dowód wykonania pracy zgodny ze standardem NIP-13, dzięki czemu transmitery i czytelnicy mogą je zweryfikować pod kątem spamu. Im wyższa wartość, tym wydobycie trwa wykładniczo dłużej. Po wygenerowaniu posty są publikowane w tle.</string>
<string name="pow_difficulty_off">Wył.</string>
<string name="pow_custom_difficulty_title">Indywidualny poziom trudności</string>
<string name="pow_custom_difficulty_explainer">Dopasuj wartość docelową pod kątem początkowych zerowych bitów.</string>
<string name="pow_categories_title">Co wydobywać</string>
<string name="pow_categories_explainer">Zdarzenia, w przypadku których czas ma kluczowe znaczenie (autoryzacja transmitera, zlecenia zap, komunikaty dotyczące portfela i sygnatariusza, wersje robocze, listy), nigdy nie są wydobywane.</string>
<string name="pow_category_short_notes">Krótkie wpisy &amp; odpowiedzi</string>
<string name="pow_category_short_notes_explainer">Główne źródło spamu w sieci</string>
<string name="pow_category_comments">Komentarze</string>
<string name="pow_category_comments_explainer">Odpowiedzi na artykuły, pliki i inne treści</string>
<string name="pow_category_reports">Zgłoszenia</string>
<string name="pow_category_reports_explainer">Transmitery oceniają raporty pod kątem ich kosztów</string>
<string name="pow_category_long_form">Artykuły &amp; główne wydarzenia</string>
<string name="pow_category_long_form_explainer">Artykuły i główne wydarzenia; pojawiają się sporadycznie, a ich koszt jest znikomy</string>
<string name="pow_category_voice">Wiadomości głosowe</string>
<string name="pow_category_voice_explainer">Publiczne wiadomości głosowe i odpowiedzi</string>
<string name="pow_category_reposts">Powtórzenia</string>
<string name="pow_category_reposts_explainer">Duża liczba dla aktywnych użytkowników</string>
<string name="pow_category_reactions">Reakcje</string>
<string name="pow_category_reactions_explainer">Wysoki wolumen; każde polubienie zużywa baterię</string>
<string name="pow_category_public_chat">Publiczny &amp; czat na żywo</string>
<string name="pow_category_public_chat_explainer">Opóźnienia w wydobyciu zakłócają płynność rozmowy</string>
<string name="pow_category_gift_wraps">Zawijanie wiadomości prywatnych</string>
<string name="pow_category_gift_wraps_explainer">Pozwólmy, aby transmitery DM filtrowały spam w skrzynce odbiorczej; wydobywana jest tylko zewnętrzna otoczka, nigdy Twoja wiadomość</string>
<string name="pow_category_other_public">Inne publiczne treści</string>
<string name="pow_category_other_public_explainer">Ankiety, statusy na żywo, ogłoszenia i wszystkie inne treści publiczne</string>
<string name="pow_mining_title">Wydobywanie dowodu wykonania pracy</string>
<plurals name="pow_mining_progress">
<item quantity="one">Wydobycie w systemie proof of work… (%1$d wpis w kolejce)</item>
<item quantity="few">Wydobywanie w systemie proof of work… (%1$d wpisów w kolejce)</item>
<item quantity="many">Wydobywanie w systemie proof of work… (%1$d wpisów w kolejce)</item>
<item quantity="other">Dowód pracy w górnictwie… (%1$d posty w kolejce)</item>
</plurals>
<plurals name="pow_mining_job">
<item quantity="one">%1$s • wydobywanie z szybkością %2$d bitów</item>
<item quantity="few">%1$s • wydobywanie z szybkością %2$d bitów</item>
<item quantity="many">%1$s • wydobywanie z szybkością %2$d bitów</item>
<item quantity="other">%1$s • wydobywanie z szybkością %2$d bitów</item>
</plurals>
<plurals name="pow_queued_job">
<item quantity="one">%1$s • oczekuje na wydobycie przy %2$d bitach</item>
<item quantity="few">%1$s • oczekuje na wydobycie przy %2$d bitach</item>
<item quantity="many">%1$s • oczekuje na wydobycie przy %2$d bitach</item>
<item quantity="other">%1$s • oczekuje na wydobycie przy %2$d bitach</item>
</plurals>
<plurals name="pow_option_default_on">
<item quantity="one">Domyślnie (%1$d bit)</item>
<item quantity="few">Domyślnie (%1$d bitów)</item>
<item quantity="many">Domyślnie (%1$d bitów)</item>
<item quantity="other">Domyślne (%1$d bity)</item>
</plurals>
<string name="pow_option_default_off">Domyślne (wyłączone)</string>
<string name="pow_option_off">Wyłączone dla tego postu</string>
<plurals name="pow_option_bits">
<item quantity="one">%1$d bit</item>
<item quantity="few">%1$d bitów</item>
<item quantity="many">%1$d bitów</item>
<item quantity="other">%1$d bity</item>
</plurals>
<string name="pow_notification_channel_name">Wydobywanie dowodów wykonania pracy</string>
<string name="pow_notification_channel_description">Wyświetla posty, które wciąż generują dowód pracy zgodnie ze standardem NIP-13, aby proces ten mógł się zakończyć po zamknięciu aplikacji.</string>
<string name="pow_notification_cancel_all">Anuluj</string>
<string name="pow_difficulty_estimate">◆ %1$s na posta na tym urządzeniu</string>
<plurals name="pow_estimate_seconds">
<item quantity="one">%1$d sekundę</item>
<item quantity="few">%1$d sekund</item>
<item quantity="many">%1$d sekund</item>
<item quantity="other">%1$d sekund</item>
</plurals>
<plurals name="pow_estimate_minutes">
<item quantity="one">%1$d minutę</item>
<item quantity="few">%1$s minut</item>
<item quantity="many">%1$d minut</item>
<item quantity="other">%1$d minut</item>
</plurals>
<plurals name="pow_estimate_hours">
<item quantity="one">%1$d godzina</item>
<item quantity="few">%1$d godzin</item>
<item quantity="many">%1$d godzin</item>
<item quantity="other">%1$d godzin</item>
</plurals>
<plurals name="pow_estimate_days">
<item quantity="one">%1$d dzień</item>
<item quantity="few">%1$d dni</item>
<item quantity="many">%1$d dni</item>
<item quantity="other">%1$d dni</item>
</plurals>
<string name="pow_time_left">◆ %1$s pozostało</string>
<string name="pow_time_left_soon">w każdej chwili</string>
<string name="pow_publish_failed">Twój %1$s zakończył wydobycie, ale nie mógł zostać opublikowany: %2$s</string>
<string name="pow_publish_failed_retry">Twój %1$s zakończył wydobywanie, ale nie udało się go opublikować. Próba zostanie powtórzona przy następnym uruchomieniu aplikacji.</string>
<string name="pow_kind_chat_message">Wiadomość Czatu</string>
<string name="pow_kind_report">Zgłoś</string>
<string name="ai_writing_use_this">Użyj tego</string>
<string name="ai_writing_dismiss">Ignoruj</string>
<string name="ai_tone_correct">Popraw</string>
@@ -713,6 +713,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fonte: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Baixar</string>
@@ -695,6 +695,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Fonte: %1$s</string>
<string name="nip82_download">Baixar</string>
<string name="nip82_section_about">Sobre</string>
@@ -727,6 +727,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Источник: %1$s</string>
<string name="nip82_download">Скачать</string>
<string name="nip82_section_about">О приложении</string>
@@ -715,6 +715,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Источник: %1$s</string>
<string name="nip82_download">Скачать</string>
<string name="nip82_section_about">О приложении</string>
@@ -262,7 +262,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="known">Poznani</string>
<string name="new_requests">Nova prošnja</string>
<string name="blocked_users">Blokirani uporabniki</string>
<string name="new_threads">Nove objave</string>
<string name="new_threads">Nove nizi objav</string>
<string name="conversations">Razprave</string>
<string name="feed">Vir vsebin</string>
<string name="mod_queue">Čakalna vrsta mod</string>
@@ -815,13 +815,12 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="relay_auth_policy_always_desc">Podpiši avtentikacijske izzive za vsak relej, ki to zahteva</string>
<string name="relay_auth_policy_never">Nikoli ne avtenticiraj</string>
<string name="relay_auth_policy_never_desc">Prezri avtentikacijske izzive vseh relejev</string>
<string name="relay_auth_policy_if_in_my_list">Samo moji releji</string>
<string name="relay_auth_policy_if_in_my_list_desc">Avtenticiraj samo z releji s seznama</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_per_relay_overrides">Prilagoditve po posameznem releju</string>
<string name="relay_auth_no_overrides">Brez preglasitev na posameznih relejih — globalna politika velja povsod</string>
<string name="relay_auth_decision_allow">Dovoli</string>
<string name="relay_auth_decision_deny">Zavrni</string>
<string name="relay_auth_remove_override">Odstrani prilagoditev</string>
<string name="nip82_repository_label">Vir: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Prenos</string>
@@ -1785,6 +1784,82 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="messages_create_public_chat">Javno</string>
<string name="messages_create_group">Skupina</string>
<string name="messages_create_public_private_chat_description">Nova javna ali zasebna skupina</string>
<string name="relay_groups_title">Skupine relejev</string>
<string name="relay_group_view_inline">V vrstici</string>
<string name="relay_group_view_grouped">Po releju</string>
<string name="relay_group_view_inline_desc">Prikaži vsako skupino kot ločen pogovor, pomešan z vašimi klepeti.</string>
<string name="relay_group_view_grouped_desc">Strni skupine posameznega releja v eno vrstico, prikazano pri njegovem najnovejšem sporočilu.</string>
<string name="relay_group_view_mode_title">Prikaz skupin NIP-29</string>
<string name="relay_group_server_label">Skupine relejev</string>
<string name="messages_settings">Sporočila</string>
<string name="relay_group_create_title">Ustvari skupino</string>
<string name="relay_group_relay_no_nip29">Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja.</string>
<string name="relay_group_create_name">Ime supine</string>
<string name="relay_group_create_topic">Tema (neobvezno)</string>
<string name="relay_group_create_private">Zasebno (branje samo za člane)</string>
<string name="relay_group_create_invite_only">Samo s povabilom</string>
<string name="relay_group_create_confirm">Ustvari</string>
<string name="relay_group_invite_title">Povabi ljudi</string>
<string name="relay_group_invite_description">Delite to kodo, da se lahko ljudje pridružijo skupini.</string>
<string name="relay_group_invite_generating">Ustvari povabilo…</string>
<string name="relay_group_invite_copied">Koda povabila kopirana</string>
<string name="relay_group_invite_code_label">Koda povabila (samo za to \"samo s povabilom\" skupino)</string>
<string name="relay_group_join_title">Pridruži se skupini</string>
<string name="relay_group_join_code_label">Koda povabila</string>
<string name="relay_group_join_code_hint">Ta skupina je \"samo s povabilom\". Vpiši kodo povabila</string>
<string name="relay_group_join_confirm">Pridruži se</string>
<string name="relay_group_pending">Zahtevano</string>
<string name="relay_group_role_admin">Administrator</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_role_member">Član</string>
<string name="relay_group_members_title">Člani</string>
<string name="relay_group_make_admin">Dodeli vlogo skrbnika</string>
<string name="relay_group_make_moderator">Dodeli vlogo moderatorja</string>
<string name="relay_group_demote_member">Odvzemi vlogo</string>
<string name="relay_group_remove_user">Odstrani iz skupine</string>
<string name="relay_group_remove_user_confirm">Odstrani %1$s iz te skupine? Izgubili bodo dostop, dokler jih ponovno ne dodate ali povabite.</string>
<string name="relay_group_edit_title">Uredi skupino</string>
<string name="relay_group_edit_name">Ime skupine</string>
<string name="relay_group_edit_topic">Tema (nebvezno)</string>
<string name="relay_group_edit_private">Zasebno (branje samo za člane)</string>
<string name="relay_group_edit_invite_only">Samo s povabilom</string>
<string name="relay_group_edit_confirm">Shrani</string>
<string name="relay_group_menu_members">Člani</string>
<string name="relay_group_menu_edit">Uredi skupino</string>
<string name="relay_group_threads_title">Nizi objav</string>
<string name="relay_group_open">Odpri skupino</string>
<string name="relay_group_channels_empty">Na tem releju še ni skupin.</string>
<string name="relay_group_invite_preparing">Pripravljam povabilo…</string>
<string name="relay_group_badge_private">Zasebno</string>
<string name="relay_group_badge_invite_only">samo s povabilom</string>
<string name="relay_group_browse_invalid_url">Vnesite veljaven URL releja (wss://…)</string>
<string name="relay_group_field_name">Ime</string>
<string name="relay_group_field_about">Opis</string>
<string name="relay_group_field_picture">Slika skupine</string>
<string name="relay_group_add_photo">Dodaj fotografijo</string>
<string name="relay_group_change_photo">Zamenjaj sliko</string>
<string name="relay_group_section_discovery">Raziskuj</string>
<string name="relay_group_section_discovery_desc">Pomagajte ljudem najti to skupino. Tematike in lokacija jo bodo prikazale v ustreznih filtrih za odkrivanje (če jih relej podpira).</string>
<string name="relay_group_field_topics">Teme</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, umetnost</string>
<string name="relay_group_field_geohash">Lokacija (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">Dovoljenja</string>
<string name="relay_group_flag_private">Zasebno</string>
<string name="relay_group_flag_private_desc">Samo člani lahko berejo sporočila.</string>
<string name="relay_group_flag_invite_only">Samo s povabilom</string>
<string name="relay_group_flag_invite_only_desc">Za vstop je potrebno povabilo ali odobritev.</string>
<string name="relay_group_flag_restricted">Pošiljanje samo za člane</string>
<string name="relay_group_flag_restricted_desc">Samo člani lahko pošiljajo sporočila</string>
<string name="relay_group_flag_hidden">Skrito</string>
<string name="relay_group_flag_hidden_desc">Skrij to skupino iz javnega imenika releja.</string>
<string name="relay_group_discovery_empty">Iščem skupine po vaših relejih…</string>
<string name="relay_group_discovery_empty_filtered">S tem filtrom še ni najdenih nobenih skupin.</string>
<string name="relay_group_favorite_relay">Dodaj ta rele med priljubljene</string>
<string name="relay_group_threads_empty">Še ni nizov objav. Začni jih z + gumbom.</string>
<string name="relay_group_thread_new">Nov niz objav</string>
<string name="relay_group_thread_untitled">Brez naslova</string>
<string name="relay_group_thread_title_label">Naslov</string>
<string name="messages_new_message">Zasebno</string>
<string name="messages_new_message_to">Za</string>
<string name="messages_new_message_subject">Zadeva</string>
@@ -710,6 +710,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Izvor: %1$s</string>
<string name="nip82_download">Preuzmi</string>
<string name="nip82_section_about">O programu</string>
@@ -716,6 +716,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Källa: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Ladda ner</string>
@@ -705,6 +705,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Chanzo: %1$s</string>
<string name="nip82_download">Pakua</string>
<string name="nip82_section_about">Kuhusu</string>
@@ -709,6 +709,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">மூலம்: %1$s</string>
<string name="nip82_download">பதிவிறக்கவும்</string>
<string name="nip82_section_about">பற்றி</string>
@@ -692,6 +692,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">ต้นทาง: %1$s</string>
<string name="nip82_download">ดาวน์โหลด</string>
<string name="nip82_section_about">เกี่ยวกับ</string>
@@ -709,6 +709,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Kaynak: %1$s</string>
<string name="nip82_download">İndir</string>
<string name="nip82_section_about">Hakkında</string>
@@ -724,6 +724,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Джерело: %1$s</string>
<string name="nip82_download">Завантажити</string>
<string name="nip82_section_about">Про застосунок</string>
@@ -712,6 +712,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Manba: %1$s</string>
<string name="nip82_download">Yuklab olish</string>
<string name="nip82_section_about">Haqida</string>
@@ -691,6 +691,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">Nguồn: %1$s</string>
<string name="nip82_download">Tải xuống</string>
<string name="nip82_section_about">Giới thiệu</string>
+209 -4
View File
@@ -774,17 +774,53 @@
<string name="relay_auth_settings_title">中继身份认证</string>
<string name="relay_auth_search_keywords">auth authentication relay sign verify nip-42 identity</string>
<string name="relay_auth_global_policy">全局政策</string>
<string name="relay_auth_custom_section">登录什么</string>
<string name="relay_auth_policy_always">总是认证身份</string>
<string name="relay_auth_policy_always_desc">为每个请求身份认证挑战的中继签署它</string>
<string name="relay_auth_policy_never">从不认证</string>
<string name="relay_auth_policy_never_desc">忽略所有中继的认证挑战</string>
<string name="relay_auth_policy_if_in_my_list">仅我的中继</string>
<string name="relay_auth_policy_if_in_my_list_desc">仅通过您的中继列表中的中继进行身份认证</string>
<string name="relay_auth_policy_custom">自定义</string>
<string name="relay_auth_policy_custom_desc">选择登录到哪些中继。会询问你任何下面尚未允许的东西。</string>
<string name="relay_auth_toggle_my_relays">我的中继和场地</string>
<string name="relay_auth_toggle_my_relays_desc">登录到您自己的中继和您加入或收藏的公共聊天室、社区和直播。</string>
<string name="relay_auth_toggle_read_follows">阅读我关注的人的帖子</string>
<string name="relay_auth_toggle_read_follows_desc">登录到所关注者的中继下载其帖子。</string>
<string name="relay_auth_toggle_message_follows">给我关注的人留言</string>
<string name="relay_auth_toggle_message_follows_desc">登录到所关注者的中继来发送私信、回复和通知。</string>
<string name="relay_auth_toggle_message_strangers">给任何人留言</string>
<string name="relay_auth_toggle_message_strangers_desc">登录陌生人的中继来发送私信、回复和通知。默认情况下关闭;每次都要问您。</string>
<string name="relay_auth_prompt_title">向该中继确认你的身份?</string>
<string name="relay_auth_prompt_message">这个中继想要首先确认真的是你。它的运营者会看到你是哪个帐户。</string>
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">向 %1$s 发送您的消息吗?</string>
<string name="relay_auth_title_notify">通知 %1$s?</string>
<string name="relay_auth_title_read">从 %1$s 加载帖子?</string>
<string name="relay_auth_title_post_venue">发帖到 %1$s?</string>
<string name="relay_auth_title_read_venue">打开 %1$s?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">如果不这么做,您给 %1$s 的消息将不会发送。</string>
<string name="relay_auth_consequence_notify">如果不这样做, %1$s 不会收到关于此内容的通知。</string>
<string name="relay_auth_consequence_read">如果不这么做,你不会在这里看到 %1$s 的帖子。</string>
<string name="relay_auth_consequence_post_venue">如果不这么做,您给 %1$s 的消息将不会被发布。</string>
<string name="relay_auth_consequence_read_venue">如果不这么做,你不会在这里看到 %1$s。</string>
<string name="relay_auth_name_and_others">%1$s 和其他</string>
<string name="relay_auth_reason_send_dm">发送您的私信到:</string>
<string name="relay_auth_reason_notify_inbox">通知:</string>
<string name="relay_auth_reason_read_outbox">从何处下载帖子:</string>
<string name="relay_auth_reason_post_venue">发布到此房间</string>
<string name="relay_auth_reason_read_venue">打开这个房间</string>
<string name="relay_auth_reason_other">使用此中继</string>
<string name="relay_auth_reason_my_own_relay">连接到您自己的中继</string>
<string name="relay_auth_allow_once">允许一次</string>
<string name="relay_auth_always_allow">总是允许此中继</string>
<string name="relay_auth_always_deliver">始终发送我的消息</string>
<string name="relay_auth_block">屏蔽此中继</string>
<string name="relay_auth_per_relay_overrides">不同中继设置覆盖</string>
<string name="relay_auth_forget">忘记</string>
<string name="relay_auth_last_used">上次使用于 %1$s 前</string>
<string name="relay_auth_no_overrides">没有不同中继设置覆盖 — 所有中继使用全局政策</string>
<string name="relay_auth_decision_allow">允许</string>
<string name="relay_auth_decision_deny">拒绝</string>
<string name="relay_auth_remove_override">删除覆盖</string>
<string name="nip82_repository_label">来源: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">下载</string>
@@ -1721,6 +1757,106 @@
<string name="messages_create_public_chat">公开</string>
<string name="messages_create_group">组</string>
<string name="messages_create_public_private_chat_description">新建公开或私人群组</string>
<string name="relay_groups_title">中继组</string>
<string name="relay_group_view_inline">行内</string>
<string name="relay_group_view_grouped">根据中继</string>
<string name="relay_group_view_inline_desc">将每个群显示为单独对话,与您的聊天混合。</string>
<string name="relay_group_view_grouped_desc">将每个中继的群组折叠为单行,放在最新消息上。</string>
<string name="relay_group_view_mode_title">NIP-29 群组显示</string>
<string name="relay_group_server_label">中继组</string>
<string name="relay_groups_button">群组</string>
<string name="messages_settings">消息</string>
<string name="relay_group_create_title">建群</string>
<string name="relay_group_relay_no_nip29">此继电器不支持NIP-29中继群。 在这里创建的群将无法工作 — — 其名称、成员和消息将不会由中继管理。选择支持基于中继的群的中继。</string>
<string name="relay_group_create_name">群名称</string>
<string name="relay_group_create_topic">主题(可选)</string>
<string name="relay_group_create_private">私有(仅成员可读取)</string>
<string name="relay_group_create_invite_only">仅限邀请</string>
<string name="relay_group_create_confirm">创建</string>
<string name="relay_group_invite_title">邀请其他人</string>
<string name="relay_group_invite_description">分享这个代码,以便人们可以加入这个群。</string>
<string name="relay_group_invite_generating">正在创建邀请…</string>
<string name="relay_group_invite_copied">已复制邀请码</string>
<string name="relay_group_invite_code_label">邀请码 (用于此受邀加入群):</string>
<string name="relay_group_join_title">加群</string>
<string name="relay_group_join_code_label">邀请码</string>
<string name="relay_group_join_code_hint">此群只有受邀才能加入。请输入邀请码。</string>
<string name="relay_group_join_confirm">加入</string>
<string name="relay_group_pending">已请求</string>
<string name="relay_group_role_admin">管理员</string>
<string name="relay_group_role_moderator">协管</string>
<string name="relay_group_role_member">成员</string>
<string name="relay_group_members_title">成员</string>
<string name="relay_group_make_admin">设为管理员</string>
<string name="relay_group_make_moderator">设为协管</string>
<string name="relay_group_demote_member">删除职位</string>
<string name="relay_group_remove_user">从群组中删除</string>
<string name="relay_group_remove_user_confirm">从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。</string>
<string name="relay_group_edit_title">编辑群</string>
<string name="relay_group_edit_name">群名称</string>
<string name="relay_group_edit_topic">主题(可选)</string>
<string name="relay_group_edit_private">私有(仅成员可读取)</string>
<string name="relay_group_edit_invite_only">仅限受邀</string>
<string name="relay_group_edit_confirm">保存</string>
<string name="relay_group_menu_members">成员</string>
<string name="relay_group_menu_edit">编辑群</string>
<string name="relay_group_threads_title">主题帖</string>
<string name="relay_group_open">打开群组</string>
<string name="relay_group_channels_empty">此中继上尚没有群。</string>
<string name="relay_group_invite_preparing">正在准备邀请…</string>
<string name="relay_group_badge_private">私密</string>
<string name="relay_group_badge_invite_only">仅限受邀</string>
<string name="relay_group_browse_invalid_url">输入一个有效的中继 URL (wss://…)。</string>
<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_add_photo">添加照片</string>
<string name="relay_group_change_photo">更改照片</string>
<string name="relay_group_section_discovery">发现</string>
<string name="relay_group_section_discovery_desc">帮助人们找到这个群。主题和位置会把群置于匹配的发现筛选器下面 (如果托管中继支持)。</string>
<string name="relay_group_field_topics">主题</string>
<string name="relay_group_field_topics_hint">比特币,nostr,艺术品</string>
<string name="relay_group_field_geohash">位置 (gehash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">权限</string>
<string name="relay_group_flag_private">私密</string>
<string name="relay_group_flag_private_desc">只有成员可以阅读消息。</string>
<string name="relay_group_flag_invite_only">仅限受邀</string>
<string name="relay_group_flag_invite_only_desc">人们必须被邀请或获批才能加入。</string>
<string name="relay_group_flag_restricted">仅限成员发布</string>
<string name="relay_group_flag_restricted_desc">只有成员可以发布消息。</string>
<string name="relay_group_flag_hidden">未列出的</string>
<string name="relay_group_flag_hidden_desc">在中继的公共目录中隐藏此群。</string>
<string name="relay_group_discovery_empty">在您的中继中寻找群组…</string>
<string name="relay_group_discovery_empty_filtered">此筛选器尚未找到群组。</string>
<string name="relay_group_favorite_relay">将该中继加入收藏</string>
<string name="relay_group_threads_empty">还没有主题帖。用+按钮开一个。</string>
<string name="relay_group_thread_new">新主题帖</string>
<string name="relay_group_thread_untitled">无标题</string>
<string name="relay_group_thread_title_label">标题</string>
<string name="relay_group_thread_body_label">写点什么…</string>
<string name="relay_group_thread_post">发表</string>
<string name="relay_group_browse_title">查找群组</string>
<string name="relay_group_browse_description">浏览中继托管的群组。粘贴一个中继地址,或在下方选择一个。</string>
<string name="relay_group_browse_relay_label">中继地址</string>
<string name="relay_group_browse_go">浏览</string>
<string name="relay_group_browse_your_relays">您正身处的中继</string>
<string name="relay_group_browse_popular">热门中继</string>
<string name="relay_group_no_messages_yet">尚无消息</string>
<string name="relay_group_join_to_post">加入此群组发送消息。</string>
<string name="relay_group_invite_only_to_post">此群只有受邀才能加入 —— 需要邀请才能发帖。</string>
<plurals name="relay_group_member_count">
<item quantity="other">%1$d 位成员</item>
</plurals>
<plurals name="relay_group_message_count">
<item quantity="other">%1$d 条消息</item>
</plurals>
<plurals name="relay_group_message_count_capped">
<item quantity="other">超过 %1$d 条消息</item>
</plurals>
<plurals name="relay_group_follows_participating">
<item quantity="other">您关注的 %1$d 人</item>
</plurals>
<string name="messages_new_message">私人</string>
<string name="messages_new_message_to">至</string>
<string name="messages_new_message_subject">主题</string>
@@ -2836,7 +2972,7 @@
<string name="kind_follow_packs">关注包</string>
<string name="kind_reposts_16">转发(16)</string>
<string name="kind_geohash_follows">Geohash 关注</string>
<string name="kind_gift_wraps">包装礼物</string>
<string name="kind_gift_wraps">礼物包装</string>
<string name="kind_git_issue">Git 问题</string>
<string name="kind_git_patch">Git 补丁</string>
<string name="kind_git_repo">Git 仓库</string>
@@ -3258,6 +3394,75 @@
<string name="compose_signature_setting_title">签名</string>
<string name="compose_signature_setting_description">在开启新帖子、回复、引用或文章时在消息末尾添加。留空则禁用。</string>
<string name="compose_signature_setting_hint">您的签名</string>
<string name="pow_settings_title">工作证明</string>
<string name="pow_difficulty_title">难度</string>
<string name="pow_difficulty_explainer">发布前将 NIP-13 工作证明开采进你的帖子以便中继和读者可以将帖子和垃圾信息进行权重比较。挖掘较大的值所需的时间会指数级增长。挖好后会在后台发布帖子。</string>
<string name="pow_difficulty_off">关</string>
<string name="pow_custom_difficulty_title">自定义难度</string>
<string name="pow_custom_difficulty_explainer">微调前导零比特的目标。</string>
<string name="pow_categories_title">要挖什么</string>
<string name="pow_categories_explainer">时间紧迫的事件(继中继认证、 zap请求、 钱包和签名者消息、 草稿、 列表)从未被挖掘。</string>
<string name="pow_category_short_notes">短笔记 &amp; 回复</string>
<string name="pow_category_short_notes_explainer">主要的公共垃圾邮件表面</string>
<string name="pow_category_comments">评论</string>
<string name="pow_category_comments_explainer">文章回复、文件和其他内容</string>
<string name="pow_category_reports">举报</string>
<string name="pow_category_reports_explainer">中继根据开销对举报进行加权</string>
<string name="pow_category_long_form">长文 &amp; 重点</string>
<string name="pow_category_long_form_explainer">文章和重点;不经常发生;费用微不足道</string>
<string name="pow_category_voice">语音消息</string>
<string name="pow_category_voice_explainer">公共语音帖子和回复</string>
<string name="pow_category_reposts">转发</string>
<string name="pow_category_reposts_explainer">活跃用户的高容量</string>
<string name="pow_category_reactions">回应</string>
<string name="pow_category_reactions_explainer">量最大的类型;挖掘每个赞都会消耗电量</string>
<string name="pow_category_public_chat">公开 &amp; 实时聊天</string>
<string name="pow_category_public_chat_explainer">采矿延迟伤害对话流</string>
<string name="pow_category_gift_wraps">私密消息包装器</string>
<string name="pow_category_gift_wraps_explainer">让私信中继过滤收件箱垃圾邮件;只挖掘外部包装,永远不会挖掘你的消息</string>
<string name="pow_category_other_public">其他公开内容</string>
<string name="pow_category_other_public_explainer">投票、实时状态、分类和所有其他所有公开信息</string>
<string name="pow_mining_title">挖掘工作证明</string>
<plurals name="pow_mining_progress">
<item quantity="other">挖掘工作证明… (队列中有 %1$d 篇帖子)</item>
</plurals>
<plurals name="pow_mining_job">
<item quantity="other">%1$s • 在 %2$d 比特挖掘中</item>
</plurals>
<plurals name="pow_queued_job">
<item quantity="other">%1$s • 正在等待在 %2$d 位上开采</item>
</plurals>
<plurals name="pow_option_default_on">
<item quantity="other">默认 (%1$d 比特)</item>
</plurals>
<string name="pow_option_default_off">默认 (关闭)</string>
<string name="pow_option_off">此帖关闭</string>
<plurals name="pow_option_bits">
<item quantity="other">%1$d 比特</item>
</plurals>
<string name="pow_notification_channel_name">工作证明开采</string>
<string name="pow_notification_channel_description">显示仍在挖掘 NIP-13 工作证明的帖子,以便在你离开应用后它们可以完成挖掘。</string>
<string name="pow_notification_cancel_all">取消</string>
<string name="pow_difficulty_estimate">此设备上每个帖子约为 %1$s</string>
<string name="pow_estimate_instant">&lt;1 秒</string>
<plurals name="pow_estimate_seconds">
<item quantity="other">%1$d 秒</item>
</plurals>
<plurals name="pow_estimate_minutes">
<item quantity="other">%1$d 分钟</item>
</plurals>
<plurals name="pow_estimate_hours">
<item quantity="other">%1$d 小时</item>
</plurals>
<plurals name="pow_estimate_days">
<item quantity="other">%1$d 天</item>
</plurals>
<string name="pow_time_left">剩余约 %1$s</string>
<string name="pow_time_left_soon">很快</string>
<string name="pow_publish_failed">您的 %1$s 完成了挖掘,但无法发布: %2$s</string>
<string name="pow_publish_failed_retry">您的 %1$s 完成了挖掘,但无法发布。将在下次应用程序启动时重新尝试。</string>
<string name="pow_kind_chat_message">聊天消息</string>
<string name="pow_kind_report">举报</string>
<string name="ai_writing_use_this">使用它</string>
<string name="ai_writing_dismiss">忽略</string>
<string name="ai_tone_correct">更正</string>
@@ -707,6 +707,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">来源: %1$s</string>
<string name="nip82_download">下载</string>
<string name="nip82_section_about">关于</string>
@@ -707,6 +707,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">来源: %1$s</string>
<string name="nip82_download">下载</string>
<string name="nip82_section_about">关于</string>
@@ -701,6 +701,8 @@
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<!-- Permissions management screen -->
<!-- Relay Authentication (NIP-42) settings -->
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="nip82_repository_label">來源:%1$s</string>
<string name="nip82_download">下載</string>
<string name="nip82_section_about">關於</string>
+4 -4
View File
@@ -873,7 +873,7 @@
<string name="relay_auth_toggle_my_relays">My relays and venues</string>
<string name="relay_auth_toggle_my_relays_desc">Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited.</string>
<string name="relay_auth_toggle_read_follows">Read posts from people I follow</string>
<string name="relay_auth_toggle_read_follows_desc">Log in to a follow\'s relays to download their posts.</string>
<string name="relay_auth_toggle_read_follows_desc">Log in to a follow\'s relays to download their profile information, posts and post engagement.</string>
<string name="relay_auth_toggle_message_follows">Message people I follow</string>
<string name="relay_auth_toggle_message_follows_desc">Log in to a follow\'s relays to send DMs, replies and notifications.</string>
<string name="relay_auth_toggle_message_strangers">Message anyone</string>
@@ -883,19 +883,19 @@
<!-- Action-aware titles: %1$s is the person (or "Alice and others"). -->
<string name="relay_auth_title_send_dm">Send your message to %1$s?</string>
<string name="relay_auth_title_notify">Notify %1$s?</string>
<string name="relay_auth_title_read">Load posts from %1$s?</string>
<string name="relay_auth_title_read">Load profile, posts and engagement from %1$s?</string>
<string name="relay_auth_title_post_venue">Post to %1$s?</string>
<string name="relay_auth_title_read_venue">Open %1$s?</string>
<!-- What happens if the user doesn\'t confirm, tied to what they were doing. %1$s is the person. -->
<string name="relay_auth_consequence_send_dm">If you don\'t, your message to %1$s won\'t be delivered.</string>
<string name="relay_auth_consequence_notify">If you don\'t, %1$s won\'t be notified about this.</string>
<string name="relay_auth_consequence_read">If you don\'t, you won\'t see posts from %1$s here.</string>
<string name="relay_auth_consequence_read">If you don\'t, you won\'t see the profile, posts or engagement from %1$s here.</string>
<string name="relay_auth_consequence_post_venue">If you don\'t, your message to %1$s won\'t be posted.</string>
<string name="relay_auth_consequence_read_venue">If you don\'t, you won\'t see %1$s here.</string>
<string name="relay_auth_name_and_others">%1$s and others</string>
<string name="relay_auth_reason_send_dm">Send your private message to:</string>
<string name="relay_auth_reason_notify_inbox">Notify:</string>
<string name="relay_auth_reason_read_outbox">Download posts from:</string>
<string name="relay_auth_reason_read_outbox">Download profile information, posts and engagement from:</string>
<string name="relay_auth_reason_post_venue">Post to this room</string>
<string name="relay_auth_reason_read_venue">Open this room</string>
<string name="relay_auth_reason_other">Use this relay</string>
+53
View File
@@ -45,6 +45,50 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp) apply false
alias(libs.plugins.kotlinxKover)
}
// Aggregated code coverage: `./gradlew koverXmlReportCoverage` runs the unit
// test suites of the modules below and merges their coverage into
// build/reports/kover/reportCoverage.xml (JaCoCo-compatible XML, importable by
// SonarQube — see BUILDING.md). `koverHtmlReportCoverage` renders the same
// data for humans. A named variant is used instead of Kover's default total
// report because the total for an Android project unconditionally merges
// EVERY build variant — compiling all release/benchmark variants of
// :amethyst just to measure unit tests. The "coverage" variant below picks
// one representative compilation per project: the `jvm` target for KMP/JVM
// modules and `playDebug` for :amethyst — play is the primary flavor and the
// larger code surface (both `add`s are optional so the one block fits every
// project). :nappletHost stays out — it has no tests.
dependencies {
kover(project(":amethyst"))
kover(project(":quartz"))
kover(project(":commons"))
kover(project(":quic"))
kover(project(":nestsClient"))
kover(project(":geode"))
kover(project(":cli"))
kover(project(":relayBench"))
kover(project(":desktopApp"))
}
kover {
currentProject {
createVariant("coverage") {}
}
}
subprojects {
plugins.withId("org.jetbrains.kotlinx.kover") {
configure<kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension> {
currentProject {
createVariant("coverage") {
add("jvm", optional = true)
add("playDebug", optional = true)
}
}
}
}
}
// Shared app version for all subprojects — read from gradle/libs.versions.toml.
@@ -120,6 +164,15 @@ if (sonarEnabled) {
.filter { it.startsWith("sonar.") }
.forEach { System.setProperty(it, sonarProperties.getProperty(it)) }
// Import Kover's aggregated coverage (run `./gradlew koverXmlReportCoverage`
// first) unless the operator pointed the scanner somewhere else.
if (System.getProperty("sonar.coverage.jacoco.xmlReportPaths") == null) {
System.setProperty(
"sonar.coverage.jacoco.xmlReportPaths",
layout.buildDirectory.file("reports/kover/reportCoverage.xml").get().asFile.absolutePath,
)
}
// The scanner's sonarResolver task reads AGP's generated-res-values provider
// but doesn't depend on the task that produces it — wire it up in every
// module that has both (today only :amethyst enables resValues, but the
+3 -3
View File
@@ -216,8 +216,8 @@ Army-knife verbs that operate purely on their arguments. They never touch
| `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. |
| `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. |
| `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). |
| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. |
| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. |
| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] [--threads N] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Mines on all cores by default (`--threads` to override). Exit 124 on timeout. |
| `amy pow bench` | Benchmark this machine's hash rate — `hashes_per_second` is the all-cores rate `pow mine` uses by default, `hashes_per_second_single_core` the one-thread rate — and print expected mining time at 16/20/24/28 bits. |
| `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. |
| `amy key public NSEC\|HEX` | Derive the public key from a secret key. |
| `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. |
@@ -384,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| Command | What it does |
|---|---|
| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). |
| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first, using all cores (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). |
| `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). |
| `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. |
| `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. |
+1
View File
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
alias(libs.plugins.kotlinxKover)
}
kotlin {
@@ -244,6 +244,13 @@ class DataDir(
*/
val eventsDbFile: File = File(eventsDir.parentFile ?: root, "events.db")
/**
* Persisted DNS cache (SurgeDns snapshot) under `<root>/shared/`, next to the
* event store — cross-account like the resolver itself: a relay host resolved
* by one account's crawl serves every account's next run.
*/
val dnsCacheFile: File = File(eventsDir.parentFile ?: root, "dns-cache.bin")
/**
* Machine-level operator keys for GrapeRank trusted-assertion publishing,
* rooted at `~/.amy/operator/` (the account root's parent) so a single
@@ -375,12 +382,16 @@ class DataDir(
/**
* Account names become directory names AND alias keys, so we
* keep them to a portable, shell-friendly subset. `shared` is
* reserved for the cross-account events-store sibling, and
* `current` collides with the active-account marker file.
* reserved for the cross-account events-store sibling, `current`
* collides with the active-account marker file, and `operator` is the
* machine-level operator-keys sibling (see [OperatorKeys]) that ANY
* graperank crawl/probe creates — counting it as an account broke
* single-account auto-selection with "multiple accounts (operator, …)".
*/
private val NAME_REGEX = Regex("^[a-zA-Z0-9_-]{1,64}$")
private const val SHARED_DIR_NAME = "shared"
private val RESERVED_NAMES = setOf(SHARED_DIR_NAME, CURRENT_MARKER_NAME)
private const val OPERATOR_DIR_NAME = "operator"
private val RESERVED_NAMES = setOf(SHARED_DIR_NAME, CURRENT_MARKER_NAME, OPERATOR_DIR_NAME)
fun validateName(name: String): String {
require(NAME_REGEX.matches(name)) {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.cli
import com.sun.management.UnixOperatingSystemMXBean
import com.vitorpamplona.amethyst.cli.stores.FileCashuKeysetCounterStore
import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
@@ -54,6 +55,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -88,6 +91,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import okhttp3.Request
import java.lang.management.ManagementFactory
import java.util.concurrent.TimeUnit
/**
@@ -135,6 +139,18 @@ class Context(
*/
val anonymous: Boolean = false,
) : AutoCloseable {
// Shared resolver — the SAME SurgeDns the Android app runs (stale-while-revalidate,
// single-flight, jittered 24-48h positive TTL, 10-min negative TTL), persisted under
// `<data-dir>/shared/` so repeat crawls start with the whole relay universe pre-resolved:
// every previously-seen host serves its cached answer instantly and re-verifies in the
// background instead of paying a blocking getaddrinfo per host.
private val surgeDns = SurgeDns()
private val dnsStore =
SurgeDnsStore(dataDir.dnsCacheFile, surgeDns).also {
// Best-effort warm start (~25KB read); a corrupt/missing blob just means a cold cache.
runCatching { it.load() }
}
private val okhttp =
OkHttpClient
.Builder()
@@ -149,14 +165,26 @@ class Context(
// already-open sockets, bounded by AdaptiveRelayLimiter), so it can't
// trip a relay's REQ rate-limit — it only speeds connection setup. The
// executor thread pool is unbounded on demand, so raising maxRequests
// just lets more of those short-lived handshakes proceed at once. 7s
// just lets more of those short-lived handshakes proceed at once
// (1 platform thread per in-flight handshake — ~1k is fine on a JVM,
// 10k is not). Sized against the process FD budget: every pending
// handshake and every open socket is one file descriptor. 7s
// (not 5s): a 5s cap struck too many merely-busy relays as connect
// failures — the crawl treats a connect *timeout* as retryable anyway,
// but the extra headroom lets slow-but-alive relays finish the handshake.
.connectTimeout(7, TimeUnit.SECONDS)
// DNS dominates a mass connection ramp without help: Dns.SYSTEM blocks a
// dispatcher thread per lookup, the JVM's own cache lasts ~30s (useless
// over a 30-minute crawl), a dead domain burns 10-30s of resolver
// timeouts on EVERY re-dial, and the outbox model mints hundreds of
// per-user URLs on one host — each a fresh lookup. SurgeDns (shared with
// the Android app) collapses all of it: single-flight per host,
// stale-while-revalidate positives, 10-min negative TTL, persisted
// across runs via [dnsStore].
.dns(surgeDns)
.dispatcher(
Dispatcher().apply {
maxRequests = 256
maxRequests = maxParallelHandshakes
maxRequestsPerHost = 16
},
).build()
@@ -212,7 +240,11 @@ class Context(
// on a from-scratch GrapeRank crawl than the old 100 (which drowned
// damus/nos.lol in 100 concurrent giant REQs) at equal completeness, and
// is still generous for the single-user fetches other amy commands do.
startCap = 16,
// AMY_RELAY_SUB_CAP overrides for experiments — the crawl's Phase-B
// throughput plateaus on hot-relay permit queues, and the 16-vs-100
// benchmark predates the multithreaded-crawl fix, so the sweet spot may
// sit higher; relays that complain still get demoted down the ladder.
startCap = System.getenv("AMY_RELAY_SUB_CAP")?.toIntOrNull()?.coerceIn(1, 100) ?: 16,
).also { client.addConnectionListener(it) }
/**
@@ -1030,6 +1062,10 @@ class Context(
override fun close() {
// Nothing to persist for an anonymous run (no account dir to write into).
if (!anonymous) dataDir.saveRunState(state)
// Persist the DNS cache (shared dir, account-independent) so the next run's
// connection storm starts with every known host pre-resolved. Best-effort:
// a failed save must never break the run it is summarizing.
runCatching { dnsStore.save() }
(signer as? NostrSignerRemote)?.let {
try {
it.closeSubscription()
@@ -1058,6 +1094,39 @@ class Context(
*/
private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60
/** FDs reserved for everything that isn't a relay socket (store, jars, pipes, DNS). */
private const val FD_RESERVE = 256L
/**
* The process's max-open-files limit (`ulimit -n`), the hard ceiling on
* concurrent sockets: every open WebSocket AND every in-flight handshake is
* one file descriptor, and the JVM cannot raise its own rlimit. Falls back
* to the conservative 1024 (the common soft default) when the platform bean
* doesn't expose it.
*/
val maxFileDescriptors: Long =
(ManagementFactory.getOperatingSystemMXBean() as? UnixOperatingSystemMXBean)
?.maxFileDescriptorCount ?: 1024L
/**
* Concurrent WS-upgrade handshakes (OkHttp Dispatcher.maxRequests): a quarter
* of the FD budget, so pending dials can never crowd out the sockets already
* held open (warm pool, drains, parked subs). Also bounds the transient
* platform threads OkHttp spawns — one per in-flight handshake.
*/
val maxParallelHandshakes: Int =
((maxFileDescriptors - FD_RESERVE) / 4).coerceIn(64, 1024).toInt()
/**
* Default cap for the crawl's mass pre-connect (warm pool): half the FD
* budget goes to held-open relay sockets, leaving the other half for
* in-flight handshakes, drain/parked subscriptions and headroom. At the
* common 1024-FD soft limit this is ~384; `ulimit -n 16384` unlocks the
* full 4000. Overridable per-run with --preconnect-cap.
*/
val defaultPreconnectCap: Int =
((maxFileDescriptors - FD_RESERVE) / 2).coerceIn(100, 4000).toInt()
/**
* Build a Context but require an account with a usable identity —
* signing verbs can't run without one. Throws [IllegalArgumentException]
@@ -624,6 +624,14 @@ private fun printUsage() {
| new/changed ranks >= --min-rank (default 2), skips
| unchanged, and retracts (kind:5) any card whose
| target left the graph or fell below the cutoff.
| graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/
| [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring.
| [--no-preconnect] Pre-connects every known-live relay in one parallel
| storm (seeded from the reachability cache).
| graperank probe [--timeout SECS] relay census: mass-connect every relay the store
| [--concurrency N] knows and record live/dead + measured rtt-open into
| the reachability cache (NIP-66 kind:30166), so the
| next crawl skips dead relays and waits once.
| graperank update [--down] [--up] refresh every locally-known author's WoT record kinds
| [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all
| [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes
@@ -92,6 +93,9 @@ import kotlin.math.roundToInt
* - `amy graperank score [OBSERVER]` — local only: build the graph from the store
* and score (same as bare `--offline`). Instant and param-tunable; repeat with
* different `--rigor`/`--attenuation`/cutoffs without re-crawling.
* - `amy graperank probe` — the relay census: mass-connect every relay the store
* knows and record live/dead + measured RTT into the reachability cache, so the
* next crawl skips the dead and pre-connects the living in one parallel storm.
* - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score.
*
* Sub-verbs complete the NIP-85 provider experience — the discovery layer that
@@ -133,6 +137,10 @@ object GrapeRankCommand {
private const val PROBE_TIMEOUT_MS = 2000
// args.bool on a mistyped flag silently returns false, so the one flag read from
// three different functions goes through a compile-time-checked name.
private const val NO_REACHABILITY_CACHE_FLAG = "no-reachability-cache"
// The probe does BLOCKING DNS + TCP connect, and dead-domain DNS lookups can hang
// far past the connect timeout. On the shared Dispatchers.IO those hanging lookups
// starve the crawl's own IO — measured +462s on the finishing drain at hop-3. Run
@@ -193,6 +201,7 @@ object GrapeRankCommand {
// `sync` is the pre-rename name kept as a back-compat alias; `crawl` is
// canonical (disambiguates from negentropy `amy sync` / `graperank update`).
"crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray())
"probe" -> probe(dataDir, tail.drop(1).toTypedArray())
"update" -> update(dataDir, tail.drop(1).toTypedArray())
"score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true)
else -> run(dataDir, tail)
@@ -441,11 +450,25 @@ object GrapeRankCommand {
// Aggregator kind:3 recovery for stragglers is on by default; --no-aggregators
// disables it for A/B comparison.
val aggregators = if (args.bool("no-aggregators")) emptySet() else CONTENT_AGGREGATOR_RELAYS
// Seed the crawl with relays a prior run/monitor proved dead within the cache's
// TTL, so the WS path never re-pays their connect timeouts (--no-reachability-cache
// to skip). The crawl's own final live/dead set is flushed back by the caller.
val knownDead =
if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead
// Seed the crawl from the reachability cache (--no-reachability-cache to skip):
// proven-dead relays within the TTL are never dialed (their connect timeouts
// aren't re-paid), and the proven-live universe is pre-connected in one
// parallel storm at crawl start so the connect wait is paid once, up front.
// The crawl's own final live/dead set is flushed back by the caller.
val reachability =
if (args.bool(NO_REACHABILITY_CACHE_FLAG)) null else ctx.reachability.snapshot()
val knownDead = reachability?.dead ?: emptySet()
// Mass pre-connect sizing: every warm socket is one FD, so the default is
// derived from the process's ulimit (--preconnect-cap to override,
// --no-preconnect to fall back to the top-20 warm pool).
val preconnectCap =
if (args.bool("no-preconnect")) 0 else args.intFlag("preconnect-cap", Context.defaultPreconnectCap)
if (preconnectCap > 0 && Context.maxFileDescriptors < 4096) {
System.err.println(
"[graperank] open-files limit is ${Context.maxFileDescriptors} → pre-connect capped at " +
"$preconnectCap sockets; `ulimit -n 16384` before running unlocks a faster crawl",
)
}
return GrapeRankCrawler(
client = ctx.client,
store = ctx.store,
@@ -454,6 +477,8 @@ object GrapeRankCommand {
GrapeRankCrawler.Config(
relayListDiscoveryRelays = discoveryRelays,
knownDeadRelays = knownDead,
knownLiveRelays = if (preconnectCap > 0) reachability?.live.orEmpty() else emptySet(),
preconnectCap = preconnectCap,
contentFallbackRelays = contentFallback,
contentAggregatorRelays = aggregators,
maxRounds = args.intFlag("max-rounds", Int.MAX_VALUE),
@@ -462,7 +487,7 @@ object GrapeRankCommand {
parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000,
diagnose = args.bool("diagnose"),
insertBatchSize = args.intFlag("insert-batch", 500),
drainConcurrency = args.intFlag("drain-concurrency", 24),
drainConcurrency = args.intFlag("drain-concurrency", 48),
timeoutEvictStrikes = args.intFlag("timeout-evict", 3),
// Cheap TCP reachability pre-probe (--no-probe to disable). No Tor
// transport here, so .onion relays are skipped on sight.
@@ -471,10 +496,22 @@ object GrapeRankCommand {
// shedDeadDiscovery / shardRotations keep their benchmarked-best
// Config defaults.
),
log = { System.err.println(it) },
log = crawlLogger(),
)
}
/**
* Crawl progress logger with a `[t+SSSs]` elapsed prefix, so a saved log
* attributes wall time to rounds/phases without external timestamps.
*/
private fun crawlLogger(): (String) -> Unit {
val start = System.nanoTime()
return { line ->
val secs = (System.nanoTime() - start) / 1_000_000_000
System.err.println("[t+${secs}s] $line")
}
}
/** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */
private fun reportRelayFeedback(ctx: Context) {
if (ctx.relayDiagnostics.hadFeedback()) {
@@ -496,7 +533,7 @@ object GrapeRankCommand {
args: Args,
stats: GrapeRankCrawler.Stats,
) {
if (args.bool("no-reachability-cache")) return
if (args.bool(NO_REACHABILITY_CACHE_FLAG)) return
runCatching {
ctx.reachability.record(reachable = stats.liveRelays, dead = stats.deadRelays)
System.err.println(
@@ -548,6 +585,86 @@ object GrapeRankCommand {
return 0
}
/**
* `amy graperank probe [--timeout SECS] [--concurrency N]` —
* the relay census. Mass-connects the ENTIRE relay universe the local store knows
* (every relay advertised in any stored kind:10002, deduped per host, plus
* everything already in the reachability cache) in parallel waves with a no-op
* REQ, so the "is this relay alive, and how slow?" wait is paid once, up front,
* concurrently — then records per-relay verdicts with real measured `rtt-open`
* into the NIP-66 reachability cache (kind:30166).
*
* The next `graperank crawl` reads that cache to (a) skip the dead set without
* dialing it and (b) pre-connect the live set in one storm — separating "working
* but slow" (kept; the crawler's patient park path waits for them) from "not
* working" (skipped entirely). Typical flow the first time:
* `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `graperank
* probe` → full `graperank crawl`.
*/
private suspend fun probe(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val timeoutMs = args.longFlag("timeout", 15L) * 1000
val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap)
Context.openOrAnonymous(dataDir).use { ctx ->
ctx.prepare()
val cached = ctx.reachability.snapshot()
val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead
if (universe.isEmpty()) {
Output.emit(
linkedMapOf<String, Any?>(
"probed" to 0,
"note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists",
),
)
return 0
}
System.err.println(
"[relay-probe] probing ${universe.size} relays in waves of $waveSize " +
"(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})",
)
val result =
RelayProber(ctx.client) { System.err.println(it) }
.probe(universe, timeoutMs, waveSize)
ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays())
val rtts =
result.reachable
.map { it.rttOpenMs }
.filter { it >= 0 }
.sorted()
fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100]
val slowest =
result.reachable
.filter { it.rttOpenMs >= 0 }
.sortedByDescending { it.rttOpenMs }
.take(10)
.map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) }
val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true }
Output.emit(
linkedMapOf<String, Any?>(
"probed" to result.verdicts.size,
"reachable" to result.reachable.size,
"dead" to result.dead.size,
"closed_by_policy" to authWalled,
"elapsed_ms" to result.elapsedMs,
"rtt_open_p50_ms" to pct(50),
"rtt_open_p90_ms" to pct(90),
"rtt_open_p99_ms" to pct(99),
"slowest" to slowest,
),
)
}
return 0
}
/**
* `amy graperank update [flags]` — refresh every locally-known author's WoT
* record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the
@@ -586,7 +703,7 @@ object GrapeRankCommand {
// Live author-advertised relays are always synced (--no-reachability-cache
// to reconcile every relay regardless).
val knownDead =
if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead
if (args.bool(NO_REACHABILITY_CACHE_FLAG)) emptySet() else ctx.reachability.snapshot().dead
val updater =
GrapeRankUpdater(
@@ -81,13 +81,14 @@ object PostCommand {
var powMillis: Long? = null
val readyToSign =
if (powTarget != null) {
System.err.println("mining $powTarget bits…")
val threads = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
System.err.println("mining $powTarget bits… ($threads threads)")
val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, ctx.signer.pubKey, powTarget) {
PoWMiner.mine(template, ctx.signer.pubKey, powTarget, threads) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
@@ -108,13 +108,18 @@ object PowCommands {
rest: Array<String>,
): Int {
val args = Args(rest)
val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] <template-json | ->"
val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] [--threads N] <template-json | ->"
val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage)
if (target < 1 || target > MAX_DIFFICULTY) {
return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY")
}
val threads = args.intFlag("threads", defaultThreads())
if (threads < 1) {
return Output.error("bad_args", "--threads must be >= 1")
}
val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage)
val template =
try {
@@ -142,13 +147,13 @@ object PowCommands {
val timeoutSec = args.flags["timeout"]?.toLongOrNull()
val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L }
System.err.println("mining $target bits for ${pubKey.take(8)}…")
System.err.println("mining $target bits for ${pubKey.take(8)}… ($threads threads)")
val startedAt = System.nanoTime()
val mined =
try {
withContext(Dispatchers.Default) {
PoWMiner.run(template, pubKey, target) {
PoWMiner.mine(template, pubKey, target, threads) {
deadlineNanos == null || System.nanoTime() < deadlineNanos
}
}
@@ -176,27 +181,38 @@ object PowCommands {
"pow" to PoWRankEvaluator.calculatePowRankOf(id),
"pow_target" to target,
"pow_millis" to elapsedMs,
"threads" to threads,
"template_json" to mined.toJson(),
),
)
return 0
}
/** `amy pow bench` — hash rate + expected mining time per common target. */
/**
* `amy pow bench` — single-core and all-cores hash rate, plus expected
* mining time per common target at the all-cores rate (what `pow mine`
* uses by default).
*/
private suspend fun bench(): Int {
val rate = PoWEstimator.hashesPerSecond()
val threads = defaultThreads()
val singleRate = PoWEstimator.hashesPerSecond()
val parallelRate = PoWEstimator.hashesPerSecond(threads)
Output.emit(
mapOf(
"hashes_per_second" to rate.roundToLong(),
"hashes_per_second" to parallelRate.roundToLong(),
"hashes_per_second_single_core" to singleRate.roundToLong(),
"threads" to threads,
"expected_seconds" to
listOf(16, 20, 24, 28).associate { bits ->
bits.toString() to PoWEstimator.estimateSeconds(bits, rate)
bits.toString() to PoWEstimator.estimateSeconds(bits, parallelRate)
},
),
)
return 0
}
private fun defaultThreads(): Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private fun readPayload(rest: Array<String>): String? {
val arg = rest.firstOrNull() ?: return null
val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg
+1
View File
@@ -25,6 +25,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.serialization)
alias(libs.plugins.kotlinxKover)
}
kotlin {
@@ -50,7 +50,9 @@ interface RelayAuthPermissionStore {
suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {}
) {
// no-op by default: stores that don't track rationale ignore recorded uses.
}
/** The accumulated grant rationale for [relayUrl] (purpose → counterparty pubkeys). */
suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = emptyMap()
@@ -59,7 +61,9 @@ interface RelayAuthPermissionStore {
suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = emptyMap()
/** Forgets the accumulated grant rationale for [relayUrl] (does not touch the ALLOW/DENY override). */
suspend fun clearRationale(relayUrl: String) {}
suspend fun clearRationale(relayUrl: String) {
// no-op by default: stores that don't track rationale have nothing to clear.
}
/** Epoch-second timestamp of the last time each relay was authenticated with (for display). */
suspend fun allLastUsed(): Map<String, Long> = emptyMap()
@@ -20,9 +20,12 @@
*/
package com.vitorpamplona.amethyst.commons.service.pow
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.utils.sha256.sha256Into
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -48,18 +51,41 @@ object PoWEstimator {
private const val BATCH = 2_000
private val BENCH_DURATION = 250.milliseconds
private var cachedRate: Double? = null
// cached per worker count; guarded by benchLock (single-flight so
// concurrent first callers — e.g. the settings screen recomposing while
// the composer chip opens — share one ~250 ms benchmark instead of each
// burning the cores).
private val cachedRates = mutableMapOf<Int, Double>()
private val benchLock = Mutex()
suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double =
cachedRate ?: withContext(dispatcher) {
// single-flight: concurrent first callers (e.g. the settings screen
// recomposing while the composer chip opens) share one ~250 ms
// benchmark instead of each burning a core.
benchLock.withLock {
cachedRate ?: benchmark().also { cachedRate = it }
suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double = hashesPerSecond(1, dispatcher)
/**
* Aggregate hash rate with [workers] concurrent miners — what
* [com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner.mine] achieves when
* racing that many workers. Each worker runs its own ~250 ms benchmark
* loop concurrently and the rates are summed, so contention between
* cores is priced in.
*/
suspend fun hashesPerSecond(
workers: Int,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): Double {
val count = workers.coerceAtLeast(1)
benchLock.withLock {
cachedRates[count]?.let { return it }
return withContext(dispatcher) {
val rate =
if (count == 1) {
benchmark()
} else {
coroutineScope { List(count) { async { benchmark() } }.awaitAll().sum() }
}
cachedRates[count] = rate
rate
}
}
}
fun estimateSeconds(
difficulty: Int,
@@ -68,14 +94,16 @@ object PoWEstimator {
private fun benchmark(): Double {
val payload = ByteArray(PAYLOAD_BYTES) { (it % 251).toByte() }
// same allocation-free hashing the miner's hot loop uses
val out = ByteArray(32)
// warm up JIT/caches so the measured window reflects steady state
repeat(3 * BATCH) { sha256(payload) }
repeat(3 * BATCH) { sha256Into(out, payload, payload.size) }
val mark = TimeSource.Monotonic.markNow()
var count = 0L
while (mark.elapsedNow() < BENCH_DURATION) {
repeat(BATCH) { sha256(payload) }
repeat(BATCH) { sha256Into(out, payload, payload.size) }
count += BATCH
}
return count / mark.elapsedNow().toDouble(DurationUnit.SECONDS)
@@ -86,6 +86,13 @@ object PoWPolicy {
*/
const val MAX_DIFFICULTY = 40
/**
* How many parallel miner workers one mining job should use on a device
* with [availableProcessors] cores: half of them, so a running miner never
* starves the UI/render threads or the relay client. At least 1.
*/
fun minerWorkers(availableProcessors: Int): Int = (availableProcessors / 2).coerceAtLeast(1)
private const val LONG_FORM_DRAFT_KIND = 30024
/** NIP-51 sets and other settings-like addressable kinds. */
@@ -96,7 +96,10 @@ data class PoWJobFailure(
* to the normal sign+broadcast continuation captured at enqueue time.
*
* FIFO with at most [maxConcurrent] concurrent miners so a burst of posts
* queues up instead of spawning unbounded CPU work. Jobs are cancellable
* queues up instead of spawning unbounded CPU work. Each job's nonce search
* runs on [minerThreads] parallel workers (see PoWMiner.mine) — callers that
* bring their own mine lambda via [enqueueStaged]/[enqueueWork] should reuse
* this value to stay inside the queue's CPU budget. Jobs are cancellable
* while QUEUED or MINING; once PUBLISHING starts, cancel is a no-op.
*
* Template jobs enqueued with a [PersistedPoWJob] record are checkpointed to
@@ -115,6 +118,7 @@ data class PoWJobFailure(
class PoWPublishQueue(
private val scope: CoroutineScope,
maxConcurrent: Int = 1,
val minerThreads: Int = 1,
miningDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val persistence: PoWJobPersistence? = null,
private val onQueueActive: () -> Unit = {},
@@ -198,7 +202,7 @@ class PoWPublishQueue(
} else {
template
}
PoWMiner.run(toMine, pubKey, difficulty, isActive)
PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive)
},
publish = onMined,
)
@@ -28,6 +28,16 @@ import kotlin.test.assertTrue
class PoWPolicyTest {
val allCategories = PoWCategory.entries.toSet()
@Test
fun minerWorkersIsHalfTheCoresButNeverZero() {
assertEquals(1, PoWPolicy.minerWorkers(1))
assertEquals(1, PoWPolicy.minerWorkers(2))
assertEquals(2, PoWPolicy.minerWorkers(4))
assertEquals(3, PoWPolicy.minerWorkers(6))
assertEquals(4, PoWPolicy.minerWorkers(8))
assertEquals(1, PoWPolicy.minerWorkers(0))
}
@Test
fun difficultyOffMinesNothing() {
assertNull(PoWPolicy.shouldMine(1, 0, allCategories))
+1
View File
@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.kotlinxKover)
}
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
+10 -10
View File
@@ -135,9 +135,15 @@
]
},
{
"user": "crowdin.pretended462",
"user": "summoner001",
"languages": [
"German"
"Hungarian"
]
},
{
"user": "hypnotichemionus4",
"languages": [
"Chinese Simplified"
]
},
{
@@ -153,15 +159,9 @@
]
},
{
"user": "summoner001",
"user": "crowdin.pretended462",
"languages": [
"Hungarian"
]
},
{
"user": "hypnotichemionus4",
"languages": [
"Chinese Simplified"
"German"
]
},
{
+1
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.serialization)
application
`java-test-fixtures`
alias(libs.plugins.kotlinxKover)
}
application {
+2
View File
@@ -35,6 +35,7 @@ kmpTorResource = "409.5.0"
junit = "4.13.2"
kchesslib = "1.0.5"
kotlin = "2.4.0"
kover = "0.9.8"
kotlinxCollectionsImmutable = "0.5.0"
kotlinxCoroutinesCore = "1.11.0"
kotlinxSerialization = "1.11.0"
@@ -246,6 +247,7 @@ jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlin' }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinxKover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+1
View File
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.kotlinxKover)
}
kotlin {
+1
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.serialization)
alias(libs.plugins.vanniktech.mavenPublish)
alias(libs.plugins.kotlinxKover)
}
kotlin {
@@ -0,0 +1,176 @@
# GrapeRank crawl: connect once, wait once (connection storm)
## Problem
A from-scratch `amy graperank crawl` spends most of its wall clock **waiting to
connect** to relays, not downloading. Three compounding causes, all on our side:
1. **Sockets are torn down between drains.** `NostrClient` reconciles the relay
pool against the union of *active* subscriptions (`RelayPool.updatePool`,
sampled every 300ms). A Phase-B drain unit unsubscribes when it finishes, so
an outbox relay with no other in-flight drain is **disconnected ~300ms
later** — and the next round re-pays DNS + TCP + TLS + WS-upgrade for the
same relay. Only the top-20 "warm pool" relays kept a do-nothing
subscription open. A crawl touches ~10–15k distinct relay URLs; most get
dialed many times.
2. **Handshake concurrency was capped at 256** (OkHttp `Dispatcher.maxRequests`;
the OkHttp default is 64). Every WS upgrade is an async call holding a
dispatcher slot for its whole DNS + TCP + TLS + upgrade; a dead relay holds
a slot for the full 7s `connectTimeout`. At 256 slots, dialing 5k relays
where half are dead is ~20 serialized waves — minutes of pure waiting that
could be one parallel wait.
3. **DNS is blocking, uncached and duplicated.** OkHttp's `Dns.SYSTEM` calls
`InetAddress.getAllByName` (glibc `getaddrinfo`) on the dispatcher thread.
The JVM's own cache is ~30s positive / ~10s negative — useless across a
30-minute crawl that re-dials the same hosts every round. Worse, the outbox
model mints hundreds of per-user *URLs* on one *host*
(`filter.nostr.wine/npubA`, `/npubB`, …) and each URL is a fresh lookup. A
dead domain is the worst case: resolver timeouts (5s × 2 attempts per
nameserver) can hold a thread 10–30s, and the crawl re-resolves it from
every straggler's outbox, every round.
## Single-server limits (what bounds "1000s of connections at once")
Measured/derived for a JVM CLI on one Linux box:
| Resource | Limit | Consequence |
|---|---|---|
| File descriptors | `ulimit -n` soft (often 1024–4096; systemd default 1024 soft / 512k+ hard) | 1 FD per socket. The JVM cannot raise its own rlimit — detect via `UnixOperatingSystemMXBean` and size the storm to fit; advise `ulimit -n 16384` for big crawls. |
| Ephemeral ports | ~28k (`ip_local_port_range`) **per destination tuple** | Non-issue: connections go to thousands of *distinct* relays, each gets its own tuple space. |
| Concurrent TCP connects | Kernel: effectively unbounded (a pending SYN is just a socket in `SYN_SENT`) | Dead-IP dials cost nothing in CPU but hold their FD + dispatcher slot for the connect timeout. Parallelism is the fix; refused/reset fails in 1 RTT. |
| DNS | glibc `getaddrinfo` is blocking, ~1 thread per in-flight lookup; dead domains 10–30s | Cache positives AND negatives in-process; dedupe concurrent lookups for the same host (path-URL explosion). |
| Threads | 1 platform thread per in-flight handshake (OkHttp) | 1–2k concurrent handshakes ≈ 1–2k transient threads — fine on a 64-bit JVM (~stack is virtual), but don't go to 10k. |
| TLS | CPU per handshake (~1ms) | Negligible next to network RTTs; OkHttp shares an SSL session cache per client. |
| Middleboxes | Home-router NAT/conntrack tables (~4–16k entries) | Operational caveat for residential operators; server/VPS operators unaffected (`nf_conntrack_max` default ~256k). |
Conclusion: with FDs raised and DNS cached, a single server comfortably opens
**a few thousand concurrent connections**; the binding constraints are the FD
soft limit and the dispatcher cap, both of which we now size/raise explicitly.
## Design
Three changes, all keeping completeness identical (same relays asked the same
filters — only *when* sockets open changes):
1. **Crawl-wide warm pool = mass pre-connect.** The existing warm-pool trick
(a never-matching `ids` filter that EOSEs instantly and just holds the
socket) is extended from the top-20 relays to **every candidate relay we
know** — seeded at crawl start from the NIP-66 reachability cache's live
set, and refreshed every round with the relays learned from kind:10002s —
capped by `preconnectCap` (FD-budget-aware, busiest relays first, dead
relays excluded). Connections open in one parallel storm at start (and in
the background as new relays are learned), stay up for the whole crawl, and
every drain hits an already-open socket. This also implements the "crawl
once with --max-hops to save the relay list" flow: the first (even shallow)
crawl populates the store with 10002s and the reachability cache with
live/dead verdicts; the next crawl pre-connects that whole universe and
waits once.
2. **Transport ceilings raised (CLI).** `Dispatcher.maxRequests` 256 → 1024
(per-host stays 16 — politeness to path-multiplexed hosts is a *server*
property), and the app's `SurgeDns` (moved to quartz; stale-while-revalidate,
single-flight, 10-min negative TTL, persisted under `~/.amy/shared/`)
replaces `Dns.SYSTEM`. FD budget is detected at startup and sizes both the
dispatcher and the default `preconnectCap`. Caveat: in a proxied environment
(HTTP CONNECT), OkHttp sends the hostname to the proxy and never consults
the client-side resolver — so the DNS layer only pays off on direct-connect
deployments, and none of the A/B numbers below depend on it.
3. **`amy graperank probe` — the relay census.** Reads the full known relay
universe (every relay in stored kind:10002s + everything in the
reachability cache), mass-connects it in waves with a never-matching REQ,
and records per-relay verdicts with **real** `rtt-open` into the NIP-66
store: connected → live (with measured RTT, however slow), cannot-connect /
never-completed → dead (TTL'd, re-probed after expiry). Separates
"working but slow" (kept, given the crawler's patient park path) from
"not working" (skipped entirely) without burning crawl time on the
distinction.
## Measured A/B results (cold hop-3 crawl, fresh store per leg, same observer)
Sequential single-variable legs on the same 4-core / 4096-FD box. Completeness
gate: `contact_lists_fed` and `users_discovered` must not drop (±2% network
noise between runs is normal — relays come and go).
| leg | wall | lists fed | users found | verdict |
|---|---|---|---|---|
| baseline (all connect-storm work, 24 workers) | 638s | 18,519 | 165k | reference |
| + background parks don't gate convergence | 579s | 18,829 | 167k | **keep** |
| + `--drain-concurrency 48` | 559s | 18,817 | 169k | keep (confirmed below) |
| + `--timeout 5` (shorter fast window) | 735s | 18,524 | 164k | **reject** — more parks/retries |
| + sharded-sweep dedup | 675s* | 18,801 | 168k | keep — round wall ↓ (301s vs 336s); total noise |
| + overlap tail (no convergence park-wait, agg ∥ deletions) | 474s | 18,816 | 167k | **keep** |
| + `--drain-concurrency 48` on top | **396s** | 18,816 | 167k | **keep → new default** |
| + per-relay sub cap 16→32 (`AMY_RELAY_SUB_CAP`) | 602s | 18,478 | 164k | **reject** — 27 relays demoted us + 7 rate-limited (vs 5+1 at 16); the Phase-B plateau is relay-side service rate, not client permits |
### Reproducibility + the recovery fixpoint
A cold hop-11 rerun matched hops 1–4 within ~3% but lost the entire hop-6/7
cluster (394k vs 621k discovered at −7.5% lists): one weak backbone hour at
round 5 shifted ~30k lists from in-round arrival to the terminal aggregator
pass, whose discoveries were never crawled. Fixed by iterating rounds +
recovery to a fixpoint (recovery reveals in-budget pending → resume rounds →
recover again). Validation, cold hop-8 on the fixpoint build: **796,459
discovered / 253,213 lists** — the deepest crawl of the series (hop 5–8
populations 107k/167k/131k/72k vs 67k/110k/127k/2k before); the fixpoint fired
live (+25 recovered lists → 1 pending → extra round → +0 → converge). Known
follow-ups: each fixpoint iteration re-asks aggregators for the FULL straggler
set (~4 min per pass at 470k stragglers — dedupe to newly-added stragglers),
and the retry rounds' backbone re-fan remains the dominant tail cost (rounds
8–10 ≈ 80 min for +6.6k lists).
Final validation — **cold hop-5, fresh store, winning stack** (the workload that
never completed pre-fixes): **85.4 min, 203,903 contact lists, 391,549 users
discovered, 626,599 events, 5,794 relays**. Per-hop list completeness: 90%
(hop 2), 71% (hop 3), 56% (hop 4). Remaining known lever for a future pass:
attempt>0 retries re-fan every user to the whole backbone inside per-batch
units, so the two retry rounds cost nearly as much as first passes at a
fraction of the yield — route retry backbone re-asks as round-wide chunked
per-relay units instead.
### Retry routing: the round-wide communal sweep (Phase C)
That "remaining lever" landed as a restructure of Phase B's shared-relay
coverage. Before: every 256-user batch with retry (attempt>0) or no-outbox
users fanned the SAME ~40 backbone+fallback relays, queueing thousands of
small REQs against their 16-permit gates — measured as rounds 8–10 of a hop-8
crawl burning ~80 min for +6.6k lists, nearly all hot-relay head-of-line
blocking. After: `routeByOutbox` routes only a user's OWN-signal relays
(write relays; harvested hints when there's no outbox) and flags
communal-coverage users into a per-round set; a new **Phase C**
(`communalSweep`) asks the shared relays ONCE per round for all flagged users
in author-chunked units — the same (relay, user) pairs, ~8x fewer REQs — and
records answered-empty pairs so each subsequent round shrinks. Attempt
accounting moves to Phase C (a user burns an attempt only after both its own
relays and the shared set missed that round), `shardedSweep` now records
answered-empty pairs too, and each recovery-fixpoint iteration only asks the
aggregators for stragglers ADDED since the previous pass (a repeat full-set
pass cost ~4 min at 470k stragglers for zero new answers).
Validation, cold hop-8, same observer (reference = the fixpoint build's
232.6 min / 253,213 lists / 796,459 users): **160.7 min, 292,768 lists,
1,169,194 users discovered, 7,240 relays** — 31% less wall clock with +15.6%
lists and +47% users. The depth cliff moved out two hops: hop-7/8 populations
319,878/292,268 vs 131,051/72,389, hop-7 lists 29,445 vs 6,117. Round-level
profile: the retry rounds that defined the old tail now run in minutes
(round 8: 247s total, of which Phase C fed 5,695 of its 6,080 lists), and
Phase C itself costs 10–170s per round, folded out of the per-batch hot path.
\* total inflated by a slow round-3 network sample + the then-serial tail; the
change's own effect (straggler-round Phase A 20s → 20ms) is visible directly.
Not measurable in these legs but fixed on the way (validated separately):
- the whole crawl ran on `runBlocking`'s single-threaded event loop — thousands
of Phase-B unit coroutines starved one core while three idled, inflating
batch walls ~5x (`crawl()` now hops to `Dispatchers.IO`); this also
invalidated the old "64 workers is 2x slower" A/B;
- per-user store point queries (~8ms each on a multi-GB store) in
harvestFromStore / sweep / consumer / aggregator paths — now one chunked
author query per 300 users (hop-3 replay Phase A: 176s → 36s at identical
fed counts).
## Non-goals
- No change to REQ concurrency per relay (`AdaptiveRelayLimiter`) or the
drain worker count — the 64-worker A/B showed the *REQ* fan-out re-floods
busy hubs; this work only parallelizes and amortizes *connection setup*.
- No async-DNS library dependency; cached blocking lookups on OkHttp's
existing threads are sufficient once deduped and negative-cached.
@@ -0,0 +1,118 @@
/*
* 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.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.utils.Log
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.nullable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
/**
* Tolerant serializer for kind-0 string fields.
*
* Some clients publish structurally wrong values for NIP-01/NIP-24 profile
* fields — seen in the wild: `"nip05":{}`. With the default serializer that
* type mismatch throws, and because [MetadataEvent.contactMetaData] turns any
* parse exception into `null`, one malformed field would discard the **entire**
* profile (name, picture, about…).
*
* This serializer accepts any JSON primitive (matching the pre-existing lenient
* behavior, where a bare number or boolean decodes into a string field) and
* treats objects, arrays, and JSON null as absent rather than failing.
*
* Same rationale as [BirthdayTolerantSerializer].
*/
object TolerantStringSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantString", PrimitiveKind.STRING).nullable
override fun deserialize(decoder: Decoder): String? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
val element = decoder.decodeJsonElement()
return when {
element is JsonNull -> null
element is JsonPrimitive -> element.content
else -> {
// Log the JSON kind only, not the raw (untrusted, network-sourced) value.
Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" }
null
}
}
}
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(
encoder: Encoder,
value: String?,
) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeString(value)
}
}
}
/**
* Tolerant serializer for the kind-0 `bot` flag: accepts `true`/`false` and their
* quoted string forms; anything else (objects, arrays, other strings, JSON null)
* is treated as absent rather than failing the whole profile parse.
*/
object TolerantBooleanSerializer : KSerializer<Boolean?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantBoolean", PrimitiveKind.BOOLEAN).nullable
override fun deserialize(decoder: Decoder): Boolean? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
val element = decoder.decodeJsonElement()
if (element is JsonPrimitive && element !is JsonNull) {
val parsed = element.booleanOrNull
if (parsed != null) return parsed
}
if (element !is JsonNull) {
Log.w("TolerantBooleanSerializer") { "Ignoring non-boolean bot field (${element::class.simpleName})" }
}
return null
}
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(
encoder: Encoder,
value: Boolean?,
) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeBoolean(value)
}
}
}
@@ -35,31 +35,58 @@ class Birthday {
var day: Int? = null
}
// Every field uses a tolerant serializer: clients in the wild publish
// structurally wrong values (e.g. "nip05":{}, birthday as a string), and one
// bad field must not discard the whole profile — see TolerantStringSerializer.
@Stable
@Serializable
class UserMetadata {
@Serializable(with = TolerantStringSerializer::class)
var name: String? = null
@SerialName("display_name")
@Serializable(with = TolerantStringSerializer::class)
var displayName: String? = null
@Serializable(with = TolerantStringSerializer::class)
var picture: String? = null
@Serializable(with = TolerantStringSerializer::class)
var banner: String? = null
@Serializable(with = TolerantStringSerializer::class)
var website: String? = null
@Serializable(with = TolerantStringSerializer::class)
var about: String? = null
@Serializable(with = TolerantBooleanSerializer::class)
var bot: Boolean? = null
@Serializable(with = TolerantStringSerializer::class)
var pronouns: String? = null
@Serializable(with = BirthdayTolerantSerializer::class)
var birthday: Birthday? = null
@Serializable(with = TolerantStringSerializer::class)
var nip05: String? = null
@Serializable(with = TolerantStringSerializer::class)
var domain: String? = null
@Serializable(with = TolerantStringSerializer::class)
var lud06: String? = null
@Serializable(with = TolerantStringSerializer::class)
var lud16: String? = null
/** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */
@SerialName("clink_offer")
@Serializable(with = TolerantStringSerializer::class)
var clinkOffer: String? = null
@Serializable(with = TolerantStringSerializer::class)
var twitter: String? = null
fun anyName(): String? = displayName ?: name
@@ -80,7 +80,9 @@ interface RelayConnectionListener {
fun onEventGaveUp(
relay: IRelayClient,
event: Event,
) {}
) {
// no-op by default: listeners that don't surface delivery failures ignore it.
}
}
object EmptyConnectionListener : RelayConnectionListener
@@ -25,19 +25,30 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.utils.sha256.sha256Into
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class PoWMiner(
val buffer: MiningBuffer,
val desiredPoW: Int,
val isActive: () -> Boolean = { true },
// first nonce byte the search is allowed to change; bytes between
// nonceStarts and this index stay fixed (a parallel worker's prefix).
val searchFrom: Int = buffer.nonceStarts,
) {
val emptyBytesForDesiredPoW = desiredPoW / 8
fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(sha256(byteArray), desiredPoW, emptyBytesForDesiredPoW)
// sha256Into writes every attempt's hash here instead of allocating a fresh
// 32-byte array per hash, keeping the hot loop allocation-free.
private val hashOut = ByteArray(32)
fun run() = runDigit(buffer.nonceStarts)
fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(sha256Into(hashOut, byteArray, byteArray.size), desiredPoW, emptyBytesForDesiredPoW)
fun run() = runDigit(searchFrom)
private fun runDigit(index: Int): Boolean {
// checks once every VALID_BYTES.size^2 hashes: cheap enough to not slow
@@ -74,6 +85,10 @@ class PoWMiner(
* The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation
* to avoid having to recompute the json objects and stringify it.
*
* The search enumerates the nonce space deterministically ([VALID_BYTES]
* in order at every position), so for a given template and pubkey every
* call hashes the same sequence of candidates.
*
* [isActive] is polled while mining; returning false aborts the search with a
* [CancellationException] so callers can cancel long-running jobs cooperatively.
*/
@@ -82,6 +97,19 @@ class PoWMiner(
pubKey: HexKey,
desiredPoW: Int,
isActive: () -> Boolean = { true },
): EventTemplate<T> = search(template, pubKey, desiredPoW, isActive, "")
/**
* [noncePrefix] is kept verbatim at the front of the nonce while only the
* bytes after it are enumerated — parallel workers get distinct prefixes
* so their search spaces never overlap.
*/
private fun <T : Event> search(
template: EventTemplate<T>,
pubKey: HexKey,
desiredPoW: Int,
isActive: () -> Boolean,
noncePrefix: String,
): EventTemplate<T> {
// sha256 ids have 256 bits; anything outside would index past the
// hash (or never terminate) deep inside the hot loop.
@@ -90,7 +118,7 @@ class PoWMiner(
var nextSize = STARTING_NONCE_SIZE
do {
val initialNonce = randomBase(nextSize)
val initialNonce = noncePrefix + randomBase(nextSize)
val bytes =
EventHasherSerializer
@@ -104,9 +132,9 @@ class PoWMiner(
val startIndex = bytes.indexOf(initialNonce.encodeToByteArray())
val buffer = MiningBuffer(bytes, startIndex, startIndex + nextSize)
val buffer = MiningBuffer(bytes, startIndex, startIndex + initialNonce.length)
if (PoWMiner(buffer, desiredPoW, isActive).run()) {
if (PoWMiner(buffer, desiredPoW, isActive, startIndex + noncePrefix.length).run()) {
return EventTemplate(
template.createdAt,
template.kind,
@@ -120,5 +148,79 @@ class PoWMiner(
throw RuntimeException("Could not find PoW")
}
/**
* Distinct fixed nonce prefix for each racing worker, encoding the worker
* index in base-[VALID_CHARS].size at the smallest width that fits
* [workers] — one char up to 73 workers.
*/
private fun workerPrefix(
worker: Int,
workers: Int,
): String {
var width = 1
var capacity = VALID_CHARS.size
while (capacity < workers) {
width++
capacity *= VALID_CHARS.size
}
var remaining = worker
val prefix = CharArray(width)
for (i in width - 1 downTo 0) {
prefix[i] = VALID_CHARS[remaining % VALID_CHARS.size]
remaining /= VALID_CHARS.size
}
return prefix.concatToString()
}
/**
* Multi-core variant of [run]: races [workers] searches over disjoint
* slices of the nonce space (each worker's nonce carries a distinct fixed
* prefix) and returns the first template to reach [desiredPoW]. Workers
* share nothing but the finish flag, so the hash rate scales roughly
* linearly with cores.
*
* Throws the same [CancellationException] as [run] when [isActive] flips
* false before a nonce is found.
*/
suspend fun <T : Event> mine(
template: EventTemplate<T>,
pubKey: HexKey,
desiredPoW: Int,
workers: Int = 1,
isActive: () -> Boolean = { true },
): EventTemplate<T> {
require(workers >= 1) { "workers must be >= 1, was $workers" }
if (workers == 1) return run(template, pubKey, desiredPoW, isActive)
return coroutineScope {
val winner = CompletableDeferred<EventTemplate<T>>()
val race =
launch {
repeat(workers) { worker ->
launch(Dispatchers.Default) {
winner.complete(
search(template, pubKey, desiredPoW, {
isActive() && !winner.isCompleted
}, workerPrefix(worker, workers)),
)
}
}
}
// every worker aborting without a win means the caller's isActive
// flipped: rethrow the CancellationException the workers swallowed
// (a losing worker's complete() is a no-op, so this can't clobber
// a real result).
race.invokeOnCompletion {
winner.completeExceptionally(CancellationException("PoW mining was cancelled"))
}
try {
winner.await()
} finally {
race.cancel()
}
}
}
}
}
@@ -38,12 +38,16 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
* Only events whose kind is in [kindsToMine] are mined; anything else (e.g. the
* seal and rumor of a gift-wrapped flow) passes through untouched. Templates that
* already carry a nonce tag are not mined again.
*
* [workers] parallel searches race over disjoint nonce slices (see
* [PoWMiner.mine]); 1 keeps the historical single-threaded behavior.
*/
class PoWNostrSigner(
val signer: NostrSigner,
val desiredPoW: Int,
val kindsToMine: Set<Int>,
val isActive: () -> Boolean = { true },
val workers: Int = 1,
) : NostrSigner(signer.pubKey) {
override fun isWriteable(): Boolean = signer.isWriteable()
@@ -55,10 +59,11 @@ class PoWNostrSigner(
): T =
if (kind in kindsToMine && tags.none { PoWTag.hasTagWithContent(it) }) {
val mined =
PoWMiner.run(
PoWMiner.mine(
template = EventTemplate<T>(createdAt, kind, tags, content),
pubKey = pubKey,
desiredPoW = desiredPoW,
workers = workers,
isActive = isActive,
)
signer.sign(mined.createdAt, mined.kind, mined.tags, mined.content)
@@ -0,0 +1,265 @@
/*
* 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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeSource
/**
* Mass relay census: dials a whole relay universe in parallel waves and returns a
* per-relay [Verdict] — reachable (with the measured WebSocket-open RTT and, when the
* relay answered a no-op REQ, its time-to-EOSE) or dead (with the failure reason).
*
* The point is to pay the "is this relay alive, and how slow is it?" wait ONCE, up
* front and concurrently, instead of rediscovering it serially inside a crawl: feed
* the verdicts to [RelayReachabilityStore.recordProbed] and the next crawl skips the
* dead set entirely and pre-connects the live set in one storm.
*
* Mechanics per wave:
* - subscribe a never-matching filter (impossible event id) to every relay in the
* wave at once — the client dials them all in parallel (bounded by the transport's
* handshake concurrency), a healthy relay EOSEs immediately, and no event payload
* is ever streamed;
* - a connection listener captures the real WS-upgrade RTT per relay;
* - any app-level answer (EOSE, or even a CLOSED — an auth-walled relay is still a
* WORKING relay) marks it reachable; a connect failure marks it dead;
* - a relay with no terminal by the wave deadline is dead if it never opened the
* socket, reachable-but-slow if it did.
*
* Wave size should stay at or below the transport's concurrent-handshake cap (and
* well below the process's file-descriptor budget) — beyond that, extra relays in a
* wave just queue and eat the wave deadline without dialing.
*/
class RelayProber(
private val client: INostrClient,
private val log: (String) -> Unit = {},
) {
/** One relay's probe outcome. [rttOpenMs]/[rttEoseMs] are -1 when not observed. */
class Verdict(
val relay: NormalizedRelayUrl,
val reachable: Boolean,
val rttOpenMs: Long,
val rttEoseMs: Long,
val error: String?,
)
class Result(
val verdicts: List<Verdict>,
val elapsedMs: Long,
) {
val reachable: List<Verdict> get() = verdicts.filter { it.reachable }
val dead: List<Verdict> get() = verdicts.filter { !it.reachable }
/** Per-relay open RTT for the reachable set, 0 (= "live, latency unknown") when unobserved. */
fun reachableRttMs(): Map<NormalizedRelayUrl, Long> = reachable.associate { it.relay to it.rttOpenMs.coerceAtLeast(0) }
fun deadRelays(): Set<NormalizedRelayUrl> = dead.mapTo(HashSet()) { it.relay }
}
/**
* Probe every relay in [relays], [waveSize] at a time, giving each wave up to
* [timeoutMs] to reach terminals. Returns one [Verdict] per input relay.
*/
suspend fun probe(
relays: Collection<NormalizedRelayUrl>,
timeoutMs: Long = 15_000,
waveSize: Int = 1000,
): Result {
val mark = TimeSource.Monotonic.markNow()
val all = ArrayList<Verdict>(relays.size)
val distinct = relays.toSet()
var done = 0
for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) {
all += probeWave(wave, timeoutMs)
done += wave.size
if (distinct.size > wave.size) {
val liveSoFar = all.count { it.reachable }
log("[relay-probe] $done/${distinct.size} probed · $liveSoFar reachable")
}
}
return Result(all, mark.elapsedNow().inWholeMilliseconds)
}
private suspend fun probeWave(
wave: List<NormalizedRelayUrl>,
timeoutMs: Long,
): List<Verdict> {
val mark = TimeSource.Monotonic.markNow()
val waveSet = wave.toHashSet()
val openRtt = ConcurrentMap<NormalizedRelayUrl, Long>()
val eoseMs = ConcurrentMap<NormalizedRelayUrl, Long>()
val errors = ConcurrentMap<NormalizedRelayUrl, String>()
// Every terminal (EOSE / CLOSED / cannot-connect) pings this with its relay so
// the wait loop can stop as soon as the whole wave has resolved.
val terminals = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
val connListener =
object : RelayConnectionListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
if (relay.url in waveSet) openRtt.getOrPut(relay.url) { pingMillis.toLong() }
}
}
val subId = newSubId()
val subListener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// The filter matches nothing; any stray event still proves liveness.
eoseMs.getOrPut(relay) { mark.elapsedNow().inWholeMilliseconds }
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseMs.getOrPut(relay) { mark.elapsedNow().inWholeMilliseconds }
terminals.trySend(relay)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// A CLOSED is an app-level ANSWER (auth wall, policy, …): the relay
// is working. Record why so the caller can see the wall.
errors.getOrPut(relay) { "closed:$message" }
terminals.trySend(relay)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
errors.getOrPut(relay) { "cannot:$message" }
terminals.trySend(relay)
}
}
client.addConnectionListener(connListener)
try {
client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener)
val remaining = wave.toMutableSet()
withTimeoutOrNull(timeoutMs) {
while (remaining.isNotEmpty()) {
remaining.remove(terminals.receive())
}
}
} finally {
client.unsubscribe(subId)
client.removeConnectionListener(connListener)
terminals.close()
}
return wave.map { relay ->
val opened = openRtt[relay]
val answered = eoseMs[relay]
val error = errors[relay]
// Reachable = the socket opened OR the relay answered at the app level
// (EOSE, or a CLOSED — an auth/policy wall is still a working relay).
// Only a connect failure, or silence with no socket, is dead.
val cannot = error?.startsWith("cannot:") == true
val reachable = !cannot && (opened != null || answered != null || error != null)
Verdict(
relay = relay,
reachable = reachable,
rttOpenMs = opened ?: -1,
rttEoseMs = answered ?: -1,
error = error,
)
}
}
companion object {
// A filter no event can match (ids are 64-hex of a hash): the relay answers
// with an immediate EOSE and never streams a payload. Same trick as the
// crawler's warm pool.
private val PROBE_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
/**
* The relay universe the local store knows: every read/write relay advertised
* in any stored kind:10002. Callers typically union this with the reachability
* cache's live+dead sets so previously-probed relays are re-checked too.
* `.onion` relays are excluded unless [includeOnion] — without a Tor transport
* they'd only burn a wave slot. [maxPerAuthority] bounds how many distinct
* URLs are kept per host[:port]: paid/filter relays mint one path URL per user
* (`wss://filter.example/npubA`, `/npubB`, …), and probing hundreds of paths
* of ONE server is redundant (liveness is a server property) and rude.
*/
suspend fun knownRelayUniverse(
store: IEventStore,
includeOnion: Boolean = false,
maxPerAuthority: Int = 3,
): Set<NormalizedRelayUrl> {
val out = HashSet<NormalizedRelayUrl>()
val perAuthority = HashMap<String, Int>()
for (ev in store.query<Event>(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)))) {
if (ev !is AdvertisedRelayListEvent) continue
for (relay in ev.relaysNorm()) {
if (!includeOnion && RelayUrlNormalizer.isOnion(relay.url)) continue
if (relay in out) continue
val authority = authorityOf(relay.url)
val count = perAuthority[authority] ?: 0
if (count >= maxPerAuthority) continue
perAuthority[authority] = count + 1
out.add(relay)
}
}
return out
}
/** host[:port] between the ws/wss scheme and the first path slash. */
private fun authorityOf(url: String): String {
val afterScheme =
when {
url.startsWith("wss://") -> url.substring(6)
url.startsWith("ws://") -> url.substring(5)
else -> url
}
val slash = afterScheme.indexOf('/')
return if (slash >= 0) afterScheme.substring(0, slash) else afterScheme
}
}
}
@@ -142,6 +142,21 @@ class RelayReachabilityStore(
for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs)
}
/**
* Like [record], but with a real, per-relay measured open round-trip — the shape a
* dedicated probe (see RelayProber) produces. Each reachable relay's record carries
* ITS OWN `rtt-open`, so the cache doubles as a latency census: a later reader can
* separate fast relays from working-but-slow ones instead of only live from dead.
*/
suspend fun recordProbed(
reachableRttMs: Map<NormalizedRelayUrl, Long>,
dead: Set<NormalizedRelayUrl>,
now: Long = TimeUtils.now(),
) {
for ((relay, rtt) in reachableRttMs) writeOne(relay, up = true, now, rtt.coerceAtLeast(0))
for (relay in dead) if (relay !in reachableRttMs) writeOne(relay, up = false, now, 0)
}
private suspend fun writeOne(
relay: NormalizedRelayUrl,
up: Boolean,
@@ -0,0 +1,132 @@
/*
* 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.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TolerantPrimitiveSerializersTest {
private fun metaWith(content: String): MetadataEvent =
EventFactory.create(
id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c",
pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d",
createdAt = 1L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = content,
sig = "00".repeat(64),
) as MetadataEvent
/**
* Regression for profiles seen in the wild that publish `"nip05":{}`
* (an empty object where NIP-05 mandates a string). Before the tolerant
* serializer this threw and [MetadataEvent.contactMetaData] returned null,
* dropping the whole profile.
*/
@Test
fun objectNip05DoesNotDropTheProfile() {
val meta =
metaWith(
"""{"name":"alice","about":"welcome to follow me","picture":"https://example.com/a.jpg","nip05":{},"lud06":null,"lud16":null}""",
).contactMetaData()
assertIs<UserMetadata>(meta, "profile must still parse despite the malformed nip05")
assertEquals("alice", meta.name)
assertEquals("welcome to follow me", meta.about)
assertEquals("https://example.com/a.jpg", meta.picture)
assertNull(meta.nip05, "non-string nip05 must be ignored, not fatal")
assertNull(meta.lud06)
assertNull(meta.lud16)
}
@Test
fun otherNonStringValuesAreIgnoredFieldByField() {
val meta =
metaWith(
"""{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""",
).contactMetaData()
assertIs<UserMetadata>(meta)
assertNull(meta.name)
assertNull(meta.displayName)
assertNull(meta.picture)
assertNull(meta.website)
assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones")
}
/** Pre-existing lenient behavior: bare primitives still decode into string fields. */
@Test
fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() {
val meta = metaWith("""{"name":123,"about":true}""").contactMetaData()
assertIs<UserMetadata>(meta)
assertEquals("123", meta.name)
assertEquals("true", meta.about)
}
@Test
fun malformedBotFlagIsIgnored() {
listOf(
"""{"name":"A","bot":{}}""",
"""{"name":"A","bot":[true]}""",
"""{"name":"A","bot":"maybe"}""",
"""{"name":"A","bot":null}""",
).forEach { json ->
val meta = metaWith(json).contactMetaData()
assertIs<UserMetadata>(meta, "profile must survive bot field in $json")
assertEquals("A", meta.name)
assertNull(meta.bot)
}
}
@Test
fun stringBotFlagStillDecodes() {
val meta = metaWith("""{"name":"A","bot":"true"}""").contactMetaData()
assertIs<UserMetadata>(meta)
assertEquals(true, meta.bot)
}
/**
* Content that is not JSON at all (some relays hand back things like
* "Relay initialized") cannot be salvaged: contactMetaData must return
* null without throwing.
*/
@Test
fun nonJsonContentReturnsNull() {
assertNull(metaWith("Relay initialized").contactMetaData())
assertNull(metaWith("").contactMetaData())
}
/** Dropped fields must not leak back into the serialized profile as nulls. */
@Test
fun droppedFieldsAreOmittedOnSerialization() {
val meta = metaWith("""{"name":"A","nip05":{}}""").contactMetaData()
assertIs<UserMetadata>(meta)
val serialized = JsonMapper.toJson(meta)
assertTrue("nip05" !in serialized, "a dropped nip05 should not be serialized back out: $serialized")
assertTrue("\"name\":\"A\"" in serialized, "valid fields must round-trip: $serialized")
}
}
@@ -0,0 +1,91 @@
/*
* 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.quartz.nip13Pow
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
class PoWMinerParallelTest {
val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val baseTemplate =
EventTemplate<TextNoteEvent>(
1683596206,
TextNoteEvent.KIND,
emptyArray(),
"A note to mine",
)
@Test
fun parallelMinerFindsAValidPoW() =
runTest {
val desiredPoW = 12
val mined = PoWMiner.mine(baseTemplate, pubKey, desiredPoW, workers = 4)
val powTag = mined.tags.firstNotNullOfOrNull { PoWTag.parse(it) }
assertNotNull(powTag, "mined template must carry a nonce tag")
val id =
sha256(
EventHasherSerializer.fastMakeJsonForId(
pubKey = pubKey,
createdAt = mined.createdAt,
kind = mined.kind,
tags = mined.tags,
content = mined.content,
),
)
assertTrue(
PoWRankEvaluator.atLeastPowRank(id, desiredPoW, desiredPoW / 8),
"mined id must reach the desired PoW",
)
}
@Test
fun cancellationAbortsAllParallelWorkers() =
runTest {
val start = TimeSource.Monotonic.markNow()
// 256 bits never completes; only the isActive deadline can end the run.
assertFailsWith<CancellationException> {
PoWMiner.mine(baseTemplate, pubKey, 256, workers = 4) {
start.elapsedNow() < 150.milliseconds
}
}
assertTrue(
start.elapsedNow() < 5_000.milliseconds,
"all workers must stop shortly after isActive flips, took ${start.elapsedNow()}",
)
}
}
@@ -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.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RelayProberUniverseTest {
private fun norm(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!!
private fun relayInfo(url: String) = AdvertisedRelayInfo(norm(url), AdvertisedRelayType.BOTH)
@Test
fun collectsAdvertisedRelaysAcrossAuthors() =
runBlocking {
val store = EventStore(null)
store.insert(AdvertisedRelayListEvent.create(listOf(relayInfo("wss://a.example"), relayInfo("wss://b.example")), NostrSignerSync()))
store.insert(AdvertisedRelayListEvent.create(listOf(relayInfo("wss://b.example"), relayInfo("wss://c.example")), NostrSignerSync()))
val universe = RelayProber.knownRelayUniverse(store)
assertEquals(setOf(norm("wss://a.example"), norm("wss://b.example"), norm("wss://c.example")), universe)
store.close()
}
@Test
fun capsPathUrlsPerAuthority() =
runBlocking {
val store = EventStore(null)
// One paid/filter host advertised as one path URL per user.
val paths = (1..10).map { relayInfo("wss://filter.example/user$it") }
store.insert(AdvertisedRelayListEvent.create(paths + relayInfo("wss://solo.example"), NostrSignerSync()))
val universe = RelayProber.knownRelayUniverse(store, maxPerAuthority = 3)
assertEquals(3, universe.count { it.url.contains("filter.example") }, "per-authority URLs must be capped")
assertTrue(norm("wss://solo.example") in universe)
store.close()
}
@Test
fun onionRelaysAreExcludedByDefault() =
runBlocking {
val store = EventStore(null)
store.insert(
AdvertisedRelayListEvent.create(
listOf(relayInfo("wss://clear.example"), relayInfo("ws://someonionaddressabcdefghijklmnop.onion")),
NostrSignerSync(),
),
)
val universe = RelayProber.knownRelayUniverse(store)
assertEquals(setOf(norm("wss://clear.example")), universe)
val withOnion = RelayProber.knownRelayUniverse(store, includeOnion = true)
assertEquals(2, withOnion.size)
store.close()
}
@Test
fun recordProbedKeepsPerRelayRttAndLiveWins() =
runBlocking {
val store = EventStore(null)
val reach = RelayReachabilityStore(store, NostrSignerInternal(KeyPair()))
val fast = norm("wss://fast.example")
val slow = norm("wss://slow.example")
val dead = norm("wss://dead.example")
reach.recordProbed(mapOf(fast to 42L, slow to 9000L), setOf(dead, slow))
val snapshot = reach.snapshot()
// A relay both probed-live and reported dead stays live (live wins).
assertEquals(setOf(fast, slow), snapshot.live)
assertEquals(setOf(dead), snapshot.dead)
store.close()
}
}
@@ -18,7 +18,7 @@
* 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.service.okhttp
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import okhttp3.Dns
import java.net.InetAddress
@@ -74,7 +74,16 @@ class SurgeDns(
private val maxEntries: Int = 2000,
private val positiveTtlMs: Long = TimeUnit.HOURS.toMillis(24),
private val positiveTtlJitterMs: Long = TimeUnit.HOURS.toMillis(24),
private val negativeTtlMs: Long = TimeUnit.SECONDS.toMillis(10),
/**
* How long a resolution FAILURE is served from cache. 10 minutes: a dead domain's
* `getaddrinfo` burns 10-30s of resolver timeouts, and both the app's relay pool and a
* GrapeRank crawl re-dial dead hosts continuously — the relay pool's own reconnect
* backoff already reaches 5 minutes, so a 10-minute DNS negative doesn't change relay
* behavior, it only makes the retries that do happen cost microseconds. Recovery paths
* for a WRONG negative (transient blip): [staleAll] on a network-identity change
* re-tries it on next use, and the entry ages out naturally.
*/
private val negativeTtlMs: Long = TimeUnit.MINUTES.toMillis(10),
private val refreshExecutor: Executor = DEFAULT_REFRESH_EXECUTOR,
) : Dns {
private val cache = ConcurrentHashMap<String, Entry>()
@@ -205,7 +214,33 @@ class SurgeDns(
}
}
/** Drop all cached entries. Call when the network changes (e.g. WiFi <-> mobile). */
/**
* Soft-expire every entry WITHOUT discarding anything — the right response to a network
* change (WiFi <-> mobile, captive portal cleared). Most cached answers are still correct
* on the new network, so keep serving them under a "double-check" regime instead of
* assuming either way:
*
* - positives fall into the stale-while-revalidate path on their next use: the old
* answer is served instantly and a background refresh re-verifies it (and demotes it
* to negative if the new network really can't resolve it);
* - negatives are re-tried synchronously on their next use, so a host that only failed
* because of the OLD network recovers on first touch instead of waiting out its TTL.
*
* Contrast [invalidate], which throws every answer away and re-pays a blocking
* `getaddrinfo` per host — a self-inflicted cold start on what is usually a healthy cache.
*/
fun staleAll() {
val now = System.currentTimeMillis()
for ((host, entry) in cache) {
if (entry.expiresAtMillis > now) {
// replace(k, old, new): a concurrent fresh resolution wins over the staling.
cache.replace(host, entry, Entry(entry.addresses, now))
}
}
dirty.set(true)
}
/** Drop all cached entries. Prefer [staleAll] for network changes — this forces a blocking re-resolve per host. */
fun invalidate() {
cache.clear()
dirty.set(true)
@@ -18,7 +18,7 @@
* 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.service.okhttp
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import com.vitorpamplona.quartz.utils.Log
import java.io.BufferedInputStream
@@ -18,7 +18,7 @@
* 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.service.okhttp
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import okhttp3.Dns
import org.junit.Assert.assertArrayEquals
@@ -18,7 +18,7 @@
* 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.service.okhttp
package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp
import okhttp3.Dns
import org.junit.Assert.assertArrayEquals
@@ -93,6 +93,45 @@ class SurgeDnsTest {
assertEquals(1, upstream.calls("missing.example"))
}
@Test
fun `staleAll serves stale positives and revalidates on next use`() {
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
val syncRefresh = Executor { it.run() }
val dns = SurgeDns(delegate = upstream, refreshExecutor = syncRefresh)
dns.lookup("a.example")
assertEquals(1, upstream.calls("a.example"))
dns.staleAll()
// Nothing was dropped: the answer still comes straight from cache — but the
// touch double-checks upstream (background refresh, synchronous here).
assertEquals(listOf(ip("1.2.3.4")), dns.lookup("a.example"))
assertEquals(2, upstream.calls("a.example"))
// The refresh re-armed the TTL: back to a plain cache hit.
dns.lookup("a.example")
assertEquals(2, upstream.calls("a.example"))
}
@Test
fun `staleAll retries negatives on next use instead of waiting out the TTL`() {
val upstream = CountingDns(emptyMap())
val dns = SurgeDns(delegate = upstream)
assertThrows(UnknownHostException::class.java) { dns.lookup("missing.example") }
assertThrows(UnknownHostException::class.java) { dns.lookup("missing.example") }
assertEquals(1, upstream.calls("missing.example"))
dns.staleAll()
// The negative no longer short-circuits: the next use goes back upstream (sync
// path — negatives never serve stale), so a host that only failed because of
// the OLD network recovers on first touch after a network change.
assertThrows(UnknownHostException::class.java) { dns.lookup("missing.example") }
assertEquals(2, upstream.calls("missing.example"))
}
@Test
fun `expired positive entry serves stale and refreshes in background`() {
val upstream = CountingDns(mapOf("a.example" to listOf(ip("1.2.3.4"))))
+1
View File
@@ -23,6 +23,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.kotlinxKover)
}
kotlin {
+1
View File
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
alias(libs.plugins.kotlinxKover)
}
application {