Compare commits

...
32 Commits
Author SHA1 Message Date
Claude 00379719c8 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 15:00:55 +00:00
Vitor PamplonaandGitHub 0c59d0ef2e Merge pull request #4113 from vitorpamplona/claude/app-relay-connection-count-jo13xd
fix(relay): honest connected-relay count by finishing the WebSocket close handshake
2026-09-13 10:59:09 -04:00
Claude 3e9b629b8d refactor(relay): one adapter is one session; drop the lock and the socket identity checks
The adapters compared the socket OkHttp named in each callback against
the one they held, and took a monitor to make the compare-and-null
atomic and to close a window in connect() where a callback could arrive
before the field was assigned. Neither is needed: the relay client builds
a fresh adapter per dial and OkHttp binds exactly one socket to the
listener created in connect(), so anything that reaches that listener is
from this session by construction. The only question a callback has to
ask is whether the session already ended, which is one AtomicBoolean
claimed by whichever of onClosed, onFailure or disconnect() gets there
first. A compare-and-set keeps the "exactly one terminal report"
guarantee without a monitor, and there is no assignment window left to
guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 13:02:47 +00:00
Vitor PamplonaandGitHub 032546f70b Merge pull request #4114 from vitorpamplona/claude/image-blurhash-load-delay-y0o5f3
fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
2026-09-13 08:49:07 -04:00
Claude e2bbe41991 Merge remote-tracking branch 'origin/claude/image-blurhash-load-delay-y0o5f3' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 12:17:39 +00:00
Claude 25e61542ba fix: three defects this branch introduced, found auditing its own diff
**Tor's control flow was being pinned alive for the process lifetime.** The
eviction wiring subscribed to `torManager.activePortOrNull` from a never-cancelled
coroutine on `applicationIOScope`, in both managers. That flow chains to
`TorManager.status`, whose upstream is `WhileSubscribed` and runs
`launch { service.start() }` when collected — so a permanent subscriber starts
Arti at process construction and never lets it unsubscribe on background.
AppModules documents this exact hazard for the battery ledger and deliberately
watches the raw `TorService.status` instead; I wired the poisoned well four lines
away from the warning.

Rewired from signals that are plain StateFlows and therefore free to observe:
`torPrefs.torType`, `torPrefs.externalSocksPort`, and `torService.status`'s socks
port. It moves to AppModules, which is where those live and where the precedent
is; commons had no business knowing Tor's subscription hazards anyway.

**`drop(1)` promised more than it delivered.** In the manager it skipped whatever
was present when the *coroutine started*, not when the call was made, so a route
change landing in that window was swallowed. In its new home the two coincide —
this runs during AppModules construction, before anything is pooled — so the
operator now means what the comment says.

**Animated media in a Crop cell flashed its raw URL.** The loading ladder keyed
its last branch on `ratio != null`, but `mediaSizingModifier` also bounds the
height for `ContentScale.Crop`. MyAsyncImage passes dimensions/blurhash/thumbhash
all null, so every gif in a card slot — DVM covers, long-form headers,
follow-set/calendar/music cards — hit the unbounded branch on first load and drew
URL text where it used to draw nothing. The predicate wanted "is the height
bounded", so it now says so: `contentScale == Crop || ratio != null`.

Drops ProxyRouteChange.kt and its tests with the rewire. The trigger is now three
stdlib flow operators; what needed judgement was which signals are safe to watch,
and that is recorded in the comment rather than in a test of combine().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 12:16:56 +00:00
Claude 624e38ce34 Merge remote-tracking branch 'origin/claude/beautiful-brahmagupta-17clq5' into claude/beautiful-brahmagupta-17clq5 2026-09-13 12:09:54 +00:00
Claude b243184026 refactor(relay): count from the connected flow itself; drop the members snapshot
The first commit on this branch added INostrClient.connectedRelays(), a
snapshot read from the pool members, because connectedRelaysFlow could not
be trusted: a relay the pool had dropped could sit in it for minutes. That
has since been fixed at the source -- the pool clears the flow itself when
it lets a relay go, and every transport reports its session end exactly
once -- so the flow's value and the members' isConnected() move on the
same transitions, and re-reading the members on every emission was a
redundant pool walk. The notification takes its count from the emitted
set, the breakdown and the Active Subscriptions screen go back to the
flow, and the snapshot API is removed. The pool test keeps pinning what
the app actually reads: the flow drops a relay on removal and on
disconnect even when the socket layer never confirms the close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 12:07:46 +00:00
Vitor PamplonaandGitHub 41113ca259 Merge pull request #4118 from vitorpamplona/claude/confident-darwin-rbzz0f
Remove unused Guardian Project Maven repository
2026-09-13 08:04:21 -04:00
Claude 2b5ce62c6e build: drop the unused Guardian Project maven repository
Nothing in the version catalog or any module references an
info.guardianproject artifact anymore (the old tor-android/jtorctl
dependencies were replaced by kmp-tor from Maven Central). Resolving
every module's dependency graph with and without the repository yields
an identical result, so the repository is dead weight that only adds
a network lookup to every miss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmSZEn7Ub9URvxow4etZc
2026-09-13 12:01:59 +00:00
Claude 5dcaa0bf5d Revert "test(commons): make the macOS keychain probe path testable on every host"
This reverts commit 047eb197a4.
2026-09-13 11:59:46 +00:00
Claude 2afe9b9221 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 11:59:00 +00:00
Vitor PamplonaandGitHub 1590fca9b7 Merge pull request #4117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 07:52:50 -04:00
davotoulaandgithub-actions[bot] afeb70b15b chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 11:47:56 +00:00
davotoula 68a9f77337 fix(cli): fail fast when a buzz-agent wrapper can't be made executable
setExecutable's result was ignored (Sonar). The runner launches the wrapper
via sh -c <path>, so a missing exec bit only surfaced later as an opaque
permission-denied; check it at extraction time instead.
2026-09-13 13:41:25 +02:00
David KasparandGitHub 153fd89f8d Merge pull request #4116 from davotoula/fix/sonar-http-literals
fix(amethyst): use https in preview data and image-URL placeholders
2026-09-13 13:36:19 +02:00
davotoula f9b11ef68b fix(amethyst): use https in preview data and image-URL placeholders
Clears Sonar 'Using HTTP protocol is insecure' hits. None were real requests:
six are @Preview dummy strings, four are picture-URL field hints that
suggested http:// to users. The Namecoin RPC onion placeholder stays
http:// on purpose (Tor encrypts; Namecoin Core RPC has no TLS).
2026-09-13 12:19:49 +02:00
David KasparandGitHub c8262d440b Merge pull request #4107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 10:58:43 +02:00
davotoulaandgithub-actions[bot] 686975e322 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 08:22:05 +00:00
David KasparandGitHub 8e6cb1f7ac Merge branch 'main' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 10:19:56 +02:00
Claude a2b0fd9405 fix(http): drop pooled connections once per real Tor route change
Removing the per-rebuild eviction left one sliver: when the user switches Tor ON,
the direct client's idle sockets to real hosts stayed pooled for the 5-minute
keepalive. Nothing could route a request through them — OkHttp keys the pool by
`Address`, which includes the proxy — but they are real connections to real hosts
outliving the moment the user asked for everything to go through Tor.

[evictOnProxyRouteChange] closes that by watching the one signal that means the
route actually moved: `torManager.activePortOrNull`. Tor coming up (null -> 9050),
going away (9050 -> null), or moving (9050 -> 9150) each evict exactly once.
`drop(1)` keeps subscribing from counting as a change.

Deliberately not driven by the two things that misled the old code:

  - Client rebuilds. One factory mints both the proxied and the direct client and
    they share a pool, so a per-rebuild check fired on every isMobileDataProvider
    emission and every resubscribe — constantly, and never specifically on a Tor
    toggle.
  - The per-feature Tor switches (imagesViaTor, videosViaTor, …). Those change
    which of the two existing clients a request picks, not the route either one
    uses, so no pooled connection goes stale.

Wired in both managers' init, so the media and relay pools behave identically.
Six tests cover the trigger, including that re-emitting the same port never
evicts — the regression the old design had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 02:42:06 +00:00
Claude 9e380cf9dc perf(http): drop the proxy-change pool eviction in both OkHttp factories
Both factories emptied the whole connection pool whenever the proxy differed
from the last one they were handed. It was defensive, and it was not free.

It is not needed. OkHttp keys the pool by `Address`, and `Address.equalsNonHost`
compares `proxy` — so a call is only ever given a connection opened through the
very same route. A connection left over from an old proxy is already unreachable
by anything using the new one; it just ages out of the pool on its own. There was
never a stale-route connection to protect against.

The cost was real, though. `evictAll()` empties the ENTIRE shared pool, and each
factory mints BOTH clients: `DualHttpClientManager` builds defaultHttpClient
(always SOCKS, since buildLocalSocksProxy falls back to 9050 rather than
returning null) and defaultHttpClientWithoutProxy (always null) from one
instance, and they share one `rootClient.connectionPool`. A single "last proxy"
field therefore alternated forever, and every rebuild read as a route change and
dropped every warm connection the other client was relying on. Both are
stateIn(WhileSubscribed(1000)) flows collected from a composable, so it fired on
each isMobileDataProvider change and each foreground round trip — and the next
image then paid a fresh DNS + TCP + TLS. Plausibly the "first image after a pause
takes forever" stall the pingInterval above it was added for.

DualHttpClientManagerForRelays has the identical shape, so the relay factory is
fixed the same way. Less damaging there — evictAll spares connections with active
calls, so live relay sockets survived — but it was still discarding idle pooled
connections on every network flap.

This replaces the ProxyRouteTracker approach from earlier on this branch, which
kept the eviction and merely made its bookkeeping correct. Deleting the mechanism
is the better answer, and it takes the class and its tests with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:43:43 +00:00
Claude 0067db3608 refactor(notifications): trigger the count refresh on the connected flow alone
availableRelaysFlow was merged in when connectedRelaysFlow could not be
trusted to move on a removal: a relay the pool had dropped could sit in it
for minutes, so the one flow that did move on membership changes was used
as an extra wake-up and the count re-read from the pool members. The pool
now clears the connected flow itself whenever it lets a relay go, and every
transport reports its session end exactly once, so that flow emits on every
change the count can reflect and the extra trigger only added wake-ups on
membership churn. The count still comes from client.connectedRelays(),
the members' live socket state, which is what it is meant to show.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:43:11 +00:00
Claude 08529c3a74 refactor(relay): make the socket adapters own the session, and take the guard out of BasicRelayClient
The previous commit taught BasicRelayClient to remember which listener it
had wired and to ignore callbacks from any other. That put the knowledge
"this report is about a socket I already threw away" in the wrong layer:
the transport adapter is the one that owns the socket, and OkHttp names
the socket in every callback, so the adapter can tell for free.

BasicRelayClient goes back to exactly its previous code. The contract it
relies on is now written on the WebSocket interface and kept by every
transport: a session ends with exactly one terminal callback, and
disconnect() reports onClosed synchronously and forwards nothing from that
socket afterwards -- what InProcessWebSocket has always done. Both OkHttp
adapters (quartz's BasicOkHttpWebSocket and the Android app's
OkHttpWebSocket) now check ownership on every callback, claim the terminal
report under a lock so a session cannot be reported twice, and answer
disconnect() themselves instead of waiting for OkHttp, which raises nothing
for a cancel when no reader is left to fail and otherwise raises it later
on its own thread. The reconnect race this closes is the same one as
before: with disconnect()+connect() back to back, the old socket's late
failure used to land on the new connection and cancel it.

The client-level stale-socket test is replaced by adapter-level tests on
both modules: disconnect() reports once and synchronously, OkHttp's own
reaction to the cancel never surfaces, and a relay-initiated close followed
by disconnect() is still one report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:36:28 +00:00
Claude 5f53568204 Merge remote-tracking branch 'origin/main' into claude/image-blurhash-load-delay-y0o5f3
One conflict, in BlossomReadAuthTokenProvider.signOnce(). Main already carries
the same fix via PR #4108 (davotoula), landed while this branch was open. The two
are functionally identical — same third cache look, in the same place, for the
same reason — so the conflict is resolved by taking main's wholesale and dropping
mine. The file is now byte-identical to main; my add7bfe3 contributes nothing
beyond it.

What this branch still adds over main:
  - GifVideoView loading fallback (the invisible no-imeta note)
  - ProxyRouteTracker + its tests (the shared connection pool being evicted
    whenever the proxied and direct clients alternate)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:20:04 +00:00
Claude 64e0ce4b3e revert(http): restore the dispatcher limits, and record why they stay
I lowered these to 32/8 on reasoning that does not survive reading the source.

`maxRequests` is the thread ceiling — Dispatcher's executor really is
corePoolSize=0 / maxPoolSize=MAX_VALUE over a SynchronousQueue, and its own kdoc
notes a pool sized exactly to maxRequests is not even sufficient. That much was
right. The conclusions drawn from it were not:

- "128 concurrent TLS handshakes" is wrong. These hosts are HTTP/2, so concurrent
  calls to one host multiplex over a single connection; handshake count is bounded
  by distinct hosts and the pool, not by maxRequests.
- "A tighter total lets the visible images finish first" is backwards.
  promoteAndExecute walks readyAsyncCalls as a strict FIFO with no priority, and
  PrefetchFeedMedia enqueues notes before the user reaches them — so a lower cap
  makes the on-screen image queue behind those prefetches rather than start
  immediately. That is the very symptom under investigation.
- Halving maxRequestsPerHost also halves HTTP/2 stream concurrency against the
  single Blossom host a feed pulls from, which is what these were tuned for.

What is left is thread memory, and blocked threads commit little. No measurement
justified the change, so the values go back as they were. The comment now carries
the analysis so the next reader does not re-derive the same wrong intuition.

The ProxyRouteTracker fix from the same commit is unaffected and stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 00:46:21 +00:00
Claude d108ba0cc8 fix(relay): answer CLOSE in the Android socket too, and stop trusting socket callbacks for pool bookkeeping
Follow-ups from an audit of the two previous commits on this branch.

The Android app does not use quartz's BasicOkHttpWebSocket: AppModules wires
its own OkHttpWebSocket, a near-twin that decides needsReconnect() from the
OkHttpClient in use. The onClosing answer therefore only reached desktop,
CLI and geode. Mirror it here, with the same loopback RFC 6455 test. The
unit-test android.util.Log stub gets a primitive-signature isLoggable (the
boxed one it had was a different method to the JVM, which is why OkHttp
could never be built in this module's tests) plus println, so the socket can
be exercised for real.

RelayPool now clears its connected flow itself on every path where it lets
a relay go -- removeRelay, removeAllRelays and disconnect -- instead of
waiting for a callback the socket layer may never deliver. The previous
commit added a members-read snapshot and migrated three callers; the other
consumers of connectedRelaysFlow (drawer status, connection-time accounting,
"wait until connected" loops) were still reading the stale set.

BasicRelayClient retires its listener before tearing a socket down and
ignores anything a retired socket reports afterwards. OkHttp delivers
onClosed from its writer thread and a cancelled socket's failure later
still; with the pool rebuilding sessions via disconnect()+connect(), a late
callback from the old socket could null the new one, orphaning a live
connection and dialing a third. disconnect() also reports onDisconnected
itself now, so a client-initiated teardown never depends on the socket
layer confirming it.

KDoc and comments that described the unanswered-close behaviour in the
present tense, or claimed a still-desired relay reads isConnected()=false
on a silent close, are corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 23:28:47 +00:00
Claude add7bfe3ca fix(http): close the read-auth single-flight window left open before putIfAbsent
signOnce() looked at the cache a second time to catch a leader that had already
finished, but it looked too early — before claiming the in-flight slot. The gap
between that look and putIfAbsent still spans signerProvider() and an allocation,
and a leader caches its token and retires its entry inside it. A straggler in
that gap therefore put into a map the leader had just emptied, won the slot, and
signed a duplicate.

Winning the slot is not proof that nobody signed; only a look from inside it is.
Once we hold the entry no one else can be leader, and our successful put observed
the map after that leader's removal, which its cache write is ordered before — so
a token visible at that point is the last word. Hand it over and stand down.

This is pre-existing, not fallout from the dispatcher change: the same test fails
on an unmodified origin/main worktree, and aFastSignerStillSharesOneSignature
already says the window is "microseconds wide, so one round hits it only now and
then" and runs 200 rounds to catch it. It cost a duplicate signature — with a
NIP-55 external signer that is a second IPC round trip, and potentially a second
prompt, for a burst of images from one gated host.

Verified with 8 consecutive runs of the suite (1600 signing rounds), all green,
against a baseline that failed inside the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 22:52:59 +00:00
Claude b8135e76c4 fix(relay): answer a relay's WebSocket CLOSE frame so OkHttp can finish the handshake
OkHttp fires onClosed only once both peers have sent a CLOSE frame, and
sending ours is the application's job (WebSocketListener KDoc; RealWebSocket
emits onClosed solely from the writer once our Close is dequeued with the
peer's code already set; the bundled WebSocketEcho recipe answers onClosing
with close(1000, null)). BasicOkHttpWebSocket never implemented onClosing,
so a relay-initiated close left the socket half-closed: no onClosed, no
onFailure, send() still accepted and silently discarded, and a later
cancel() silent as well because no reader was left to fail. The relay
client kept believing it was connected, with its REQs live, until the 120s
ping path finally failed up to two intervals later.

Answer onClosing with close(1000, null). Verified against OkHttp 5.5.0:
onClosed then fires at once whether the relay still holds the TCP session
or has already dropped it, and the existing onClosed path in
BasicRelayClient marks the connection closed and lets the pool reconnect
under its normal backoff. Always 1000 rather than echoing the relay's code,
since close() validates the code it writes and relays may send reserved
ones.

The test drives the wrapper against a minimal RFC 6455 server on a loopback
ServerSocket (no new dependency): the relay sends CLOSE, the client must
answer with its own CLOSE frame and report onClosed with the relay's code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 22:52:37 +00:00
Claude 943e137d4e perf(http): stop wiping the media connection pool, and size the dispatcher for a phone
Two problems in the shared non-relay client, both of which make every HTTP
request cost more than it should.

The connection pool was being emptied constantly. buildHttpClient() evicted the
whole pool whenever the proxy differed from the last one it was handed, tracked
in a single field. But one factory mints BOTH long-lived variants:
DualHttpClientManager builds defaultHttpClient (always SOCKS, since
buildLocalSocksProxy falls back to 9050 rather than returning null) and
defaultHttpClientWithoutProxy (always null) from the same instance, and the two
share one rootClient.connectionPool. So the field alternated between the proxy
and null forever, and every rebuild read as a route change and wiped the pool
they share. Both are stateIn(WhileSubscribed(1000)) flows collected from a
composable, so that happened on every isMobileDataProvider change and every
foreground round trip — and the next image then paid a fresh DNS + TCP + TLS.
This is a plausible source of the "first image after a pause takes forever"
stall the pingInterval above it was added for.

ProxyRouteTracker narrows it to what the eviction was actually for: a direct
build never evicts (null is that variant's permanent route), and a proxied build
evicts only when the Tor port really moved. Nothing is lost by being this narrow
— OkHttp's Address, the pool key, already includes the proxy, so proxied and
direct connections to the same host are distinct entries that can never be
handed to each other's calls.

Second, maxRequests was 128. Dispatcher's executor is an unbounded cached pool,
so that is the thread ceiling: up to 128 threads running 128 concurrent TLS
handshakes on a handset. Nothing upstream bounds the arrival rate either —
Coil's enqueue is unbounded and PrefetchFeedMedia warms ±3 notes on both sides
of the viewport on every visible-range change — so a fast scroll really does
reach it. Past saturation, more concurrency slices the same bandwidth thinner
and pushes every image's completion out together, the on-screen one included.
32 total / 8 per host keeps the per-host lift that feeds need while letting
visible images finish and paint.

The pool fix is covered by tests. The dispatcher numbers are a reasoned choice,
not a measured one — worth a run against benchmark/ before release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:41 +00:00
Claude e076b1eaee fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
GifVideoView's Loading branch emitted DisplayBlurHash unconditionally, and
DisplayBlurHash renders nothing at all when both hashes are absent —
placeholderModel(null, null) returns null and the composable early-returns.

That is exactly the state a post with no imeta lands in. With no `dim` tag and
nothing in MediaAspectRatioCache, `ratio` is null, so mediaSizingModifier falls
to a bare fillMaxWidth() with no height constraint. The container then wraps an
empty loading state and the whole note collapses to zero height: no picture, no
URL, no spinner, just a gap in the feed for however long the fetch takes, and
then the image appearing from nowhere. Seen on a kind-1111 comment from Sidecar
whose content is a single blossom .gif URL.

UrlImageView already has the ladder this needs, so mirror it: blurhash/thumbhash
when there is one, a spinner in the reserved box when only a ratio is known, and
otherwise the URL plus a loading symbol via WaitAndDisplay. Every branch now
emits something with a height.

This is the missing feedback, not the latency — a slow fetch still takes as long
as it takes, it just stops being invisible while it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:22 +00:00
Claude 1093b3ce8a fix(notifications): count connected relays from the pool's live socket state
The always-on notification read its "Connected to N relays" from
RelayPool.connectedRelays, a set that only moves on the onConnected /
onDisconnected callbacks. That set over-reports: a relay that sent a
WebSocket CLOSE frame never produces a callback (the app does not answer
onClosing, so OkHttp fires neither onClosed nor onFailure, and the later
cancel() is silent too), so the URL lingers until the 120s ping path
finally fails. After the feeds tore down in the background this left
hundreds of already-dropped relays in the count for minutes, with no
subscription in the "show details" breakdown to justify any of them.

Expose the pool's ground truth instead: RelayPool.connectedRelayUrls()
reads each member's isConnected(), surfaced as INostrClient.connectedRelays()
(defaulting to the flow's value for pool-less clients). The notification
keeps the flows only as a trigger, merging in availableRelaysFlow because
that is the flow that moves when the pool drops such a relay, and re-reads
the live count on each sample. The "show details" breakdown and the
Active Subscriptions screen read the same source so all three agree.

A pool test pins the drift: with a socket layer that never confirms the
close, removing a relay leaves it in the flow but out of the snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 20:17:29 +00:00
35 changed files with 1312 additions and 490 deletions
@@ -184,6 +184,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -647,6 +648,41 @@ class AppModules(
onionCache = onionLocationCache,
)
// Drops pooled connections once per real Tor route change. When the user switches
// Tor on, the direct clients' idle sockets to real hosts would otherwise sit in the
// pool for its 5-minute keepalive after the user has asked for everything to go
// through Tor. No request could use them either way -- OkHttp keys the pool by
// `Address`, which includes the proxy, so a connection on a dead route is already
// unreachable -- which is why this is hygiene and not correctness, and why it is
// fine for it to be a little late.
//
// Every source here is a plain StateFlow, so subscribing costs nothing. Deliberately
// NOT torManager.activePortOrNull: that chains to TorManager.status, whose upstream
// is WhileSubscribed and calls service.start() when collected, so a process-lifetime
// subscription there would hold Arti's control flow open forever -- the same hazard
// the battery ledger above documents and sidesteps the same way.
//
// Also deliberately not the per-feature Tor switches (imagesViaTor, videosViaTor, ...):
// those change which of the two existing clients a request picks, not the route either
// one uses, so no pooled connection goes stale.
init {
applicationIOScope.launch {
combine(
torPrefs.torType,
torPrefs.externalSocksPort,
torService.status.map { it.socksPort },
) { torType, externalPort, artiPort -> Triple(torType, externalPort, artiPort) }
.distinctUntilChanged()
// Only later moves count; the route in force at process construction is the
// status quo, and nothing is pooled yet to evict.
.drop(1)
.collect {
okHttpClients.factory.evictPooledConnections()
okHttpClientForRelays.factory.evictPooledConnections()
}
}
}
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder(
@@ -320,6 +320,14 @@ class NotificationRelayService : Service() {
}
launch {
// This flow used to over-report: it is fed by socket callbacks, and until
// the OkHttp adapters answered a relay's CLOSE frame a relay-initiated close
// produced none (no onClosed, no onFailure, and a silent cancel()
// afterwards), so after the feeds tore down in the background it carried
// hundreds of already-dropped relays for minutes. The pool now clears it
// itself whenever it lets a relay go, and every transport reports its
// session end exactly once (see WebSocket), so what it emits is the count.
//
// sample() caps how often we touch the notification. During feed
// load/teardown connectedRelaysFlow churns dozens of times per second;
// posting on every delta blows past Android's notification rate limit
@@ -34,6 +34,7 @@ import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.atomic.AtomicBoolean
class OkHttpWebSocket(
val url: NormalizedRelayUrl,
@@ -41,7 +42,17 @@ class OkHttpWebSocket(
val out: WebSocketListener,
) : WebSocket {
private var usingOkHttp: OkHttpClient? = null
private var socket: okhttp3.WebSocket? = null
@Volatile private var socket: okhttp3.WebSocket? = null
/**
* Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first.
* One adapter is one session (the relay client builds a fresh one per dial, and OkHttp binds
* exactly one socket to the listener), so a callback only has to ask whether the session
* already ended. See quartz's `BasicOkHttpWebSocket` for the full reasoning; the two adapters
* differ only in how [needsReconnect] is decided.
*/
private val ended = AtomicBoolean(false)
fun buildRequest() = Request.Builder().url(url.url).build()
@@ -68,8 +79,10 @@ class OkHttpWebSocket(
}
override fun connect() {
usingOkHttp = httpClient(url)
socket = usingOkHttp?.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
if (socket != null || ended.get()) return
val client = httpClient(url)
usingOkHttp = client
socket = client.newWebSocket(buildRequest(), OkHttpWebsocketListener(out))
}
inner class OkHttpWebsocketListener(
@@ -91,35 +104,60 @@ class OkHttpWebSocket(
}
}
/** Claims the session's single terminal report. False if it already ended. */
private fun endSession(): Boolean {
if (!ended.compareAndSet(false, true)) return false
socket = null
incomingMessages.close()
job.cancel()
scope.cancel()
return true
}
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: Response,
) = out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
) {
if (ended.get()) return
out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
}
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) {
// Asynchronously send the received message to the channel.
// `trySendBlocking` is used here for simplicity within the callback,
// but it's important to understand potential thread blocking if the buffer is full.
if (ended.get()) return
// Never blocks (unlimited channel): the OkHttp reader thread must
// stay free to keep draining the socket.
incomingMessages.trySendBlocking(text)
}
override fun onClosing(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) {
// The relay sent a CLOSE frame. OkHttp fires onClosed only once BOTH peers have sent
// one, and sending ours is the application's job (WebSocketListener KDoc; its own
// WebSocketEcho recipe does exactly this). Unanswered, the socket sat half-closed:
// no onClosed, no onFailure, send() still accepted and discarded, a later cancel()
// silent too -- so the relay client believed it was connected until the 120s ping
// path failed up to two intervals later.
//
// Always 1000 rather than echoing `code`: close() validates the code it writes and
// throws on the reserved ones (1005, 1006, 1015), and a relay may send anything.
webSocket.close(1000, null)
}
override fun onClosed(
webSocket: okhttp3.WebSocket,
code: Int,
reason: String,
) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null
if (!endSession()) return
out.onClosed(code, reason)
}
@@ -128,12 +166,7 @@ class OkHttpWebSocket(
t: Throwable,
response: Response?,
) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
socket = null
if (!endSession()) return
out.onFailure(t, response?.code, response?.message)
}
}
@@ -153,9 +186,15 @@ class OkHttpWebSocket(
}
override fun disconnect() {
// uses cancel to kill the SEND stack that might be waiting
socket?.cancel()
// Claim the session ourselves and cancel (which also kills a SEND stack that might be
// waiting): OkHttp's cancel() raises no callback when no reader is left to fail, and when
// it does the failure arrives later on its own thread. The relay client needs the answer
// now, and must not hear from this socket again.
val closing = socket ?: return
if (!ended.compareAndSet(false, true)) return
socket = null
closing.cancel()
out.onClosed(1000, "client disconnect")
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
@@ -87,7 +87,7 @@ fun RenderTopButtonsPreview() {
ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) {
RenderTopButtons(
mediaData = MediaItemData("http://test.mp4"),
mediaData = MediaItemData("https://test.mp4"),
hasMultipleQualities = false,
qualityButton = {},
controllerVisible = remember { mutableStateOf(true) },
@@ -47,10 +47,13 @@ import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.gif
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size6dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -72,6 +75,15 @@ fun GifVideoView(
// remember() to avoid the recompute would cost more (slot read + N equality checks)
// than the work it saves; that's why this stays as a plain expression.
val ratio = dimensions?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(videoUri)
// Mirrors [mediaSizingModifier]: Crop gets fillMaxSize() and a known ratio gets
// aspectRatio(), both of which bound the height. Everything else is a bare
// fillMaxWidth() that wraps its content, and only THAT case needs a loading state
// with intrinsic height to keep the note from collapsing to nothing. Keying the
// fallback on `ratio` alone would put raw URL text inside every Crop card cell --
// MyAsyncImage passes dimensions/blurhash/thumbhash all null, so a gif in a card
// slot hits this on first load, before MediaAspectRatioCache knows its size.
val heightIsBounded = contentScale == ContentScale.Crop || ratio != null
val autoPlay = accountViewModel.settings.autoPlayVideos()
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
val context = LocalContext.current
@@ -108,13 +120,29 @@ fun GifVideoView(
when (state) {
is AsyncImagePainter.State.Loading -> {
DisplayBlurHash(
blurhash,
contentDescription,
contentScale,
Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
// When the height is unbounded (see [heightIsBounded]) this branch MUST
// emit something with an intrinsic height, or the box wraps nothing and the
// whole note collapses to zero -- no picture, no URL, no spinner, just a gap
// in the feed until the load finishes. DisplayBlurHash renders NOTHING when
// both hashes are absent (placeholderModel returns null), which is exactly
// what a no-imeta post hits. Mirrors UrlImageView's ladder.
if (blurhash != null || thumbhash != null) {
DisplayBlurHash(
blurhash,
contentDescription,
contentScale,
Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
} else if (heightIsBounded) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
LoadingAnimation(Size40dp, Size6dp)
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(videoUri)
}
}
}
is AsyncImagePainter.State.Success -> {
@@ -214,7 +214,7 @@ private fun Picture(
onValueChange = { bookmarkGroupInfoViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
text = "https://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -285,7 +285,7 @@ private fun Picture(
onValueChange = { postViewModel.channelPicture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
text = "https://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -87,7 +87,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
private const val PREVIEW_LIST_TITLE = "Sample List Title"
private const val PREVIEW_LIST_DESCRIPTION = "Sample List Description"
private const val PREVIEW_LIST_IMAGE = "http://some.com/image.png"
private const val PREVIEW_LIST_IMAGE = "https://some.com/image.png"
@Preview()
@Composable
@@ -238,7 +238,7 @@ private fun Picture(
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
text = "https://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -238,7 +238,7 @@ private fun Picture(
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
text = "https://mygroup.com/logo.jpg",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -85,8 +85,8 @@ fun ShowQRScreenPreview() {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
banner = "https://banner.com/test"
website = "https://mywebsite.com/test"
about = "This is the about me"
},
MetadataEvent(
@@ -1677,8 +1677,8 @@ fun RelayHeaderPreview() {
),
SpamDebugMessage(
time = TimeUtils.now() - 24000,
link1 = "http://test1.com",
link2 = "http://test2.com",
link1 = "https://test1.com",
link2 = "https://test2.com",
),
),
accountViewModel = mockAccountViewModel(),
@@ -2389,4 +2389,5 @@
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
rationale screen. Needs to be an Android resource (not a commons Compose resource)
because android:label on the manifest entry can only reference @string/. -->
<string name="health_connect_rationale_activity_label">Health Connect és Amethyst</string>
</resources>
@@ -2607,4 +2607,5 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
rationale screen. Needs to be an Android resource (not a commons Compose resource)
because android:label on the manifest entry can only reference @string/. -->
<string name="health_connect_rationale_activity_label">Health Connect i Amethyst</string>
</resources>
@@ -1210,6 +1210,10 @@
<string name="library_learning_resource">Recurso de aprendizagem</string>
<string name="library_directory">Diretório</string>
<string name="library_blossom_piece">Registro de arquivo</string>
<plurals name="library_directory_items">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d itens</item>
</plurals>
<plurals name="library_directory_more_items">
<item quantity="one">mais %1$d item</item>
<item quantity="other">mais %1$d itens</item>
@@ -2385,8 +2389,4 @@
rationale screen. Needs to be an Android resource (not a commons Compose resource)
because android:label on the manifest entry can only reference @string/. -->
<string name="health_connect_rationale_activity_label">Health Connect e Amethyst</string>
<plurals name="library_directory_items">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d itens</item>
</plurals>
</resources>
@@ -1536,6 +1536,7 @@
<string name="route_notifications">Aviseringar</string>
<!-- Label for the mixed media feed: pictures + every NIP-71 video kind + NIP-94 files.
Distinct from the "shorts" string, which labels the vertical/short-video-only feed. -->
<string name="route_media">Media</string>
<string name="route_calendars">Kalendrar</string>
<string name="route_calendar_collections">Kalenderlistor</string>
<string name="route_chess">Schack</string>
@@ -2387,6 +2388,5 @@
<!-- Health Connect: activity label, shown by Health Connect next to the link into our
rationale screen. Needs to be an Android resource (not a commons Compose resource)
because android:label on the manifest entry can only reference @string/. -->
<string name="route_media">Media</string>
<string name="health_connect_rationale_activity_label">Health Connect och Amethyst</string>
</resources>
+11 -2
View File
@@ -1,8 +1,17 @@
package android.util;
public class Log {
public static Boolean isLoggable(String tag, Integer msg) {
return true;
// Primitive signature on purpose: OkHttp's Android platform probe (AndroidLog.enableLogging)
// links against `boolean isLoggable(String, int)`, and a boxed variant is a different method.
// Answering false keeps OkHttp from installing its Android log handler, which would route
// every internal task-runner trace through println() below on the dispatcher threads.
public static boolean isLoggable(String tag, int level) {
return false;
}
public static int println(int priority, String tag, String msg) {
System.out.println(tag + ": " + msg);
return 0;
}
public static int d(String tag, String msg) {
@@ -0,0 +1,243 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.Base64
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
/**
* The Android app's socket must answer a relay-initiated close, like quartz's
* `BasicOkHttpWebSocket` (see `BasicOkHttpWebSocketCloseHandshakeTest` there for the full story).
* OkHttp fires `onClosed` only once BOTH peers have sent a CLOSE frame; until [OkHttpWebSocket]
* sent ours, a relay's close left the socket half-closed and the relay client believed it was
* connected until the ping path failed minutes later.
*
* Same minimal RFC 6455 loopback server as the quartz test: the two socket classes live in
* different modules with no shared test fixtures, and the harness is small enough to carry twice.
*/
class OkHttpWebSocketCloseHandshakeTest {
private class TinyRelay : AutoCloseable {
private val server = ServerSocket(0)
val url = NormalizedRelayUrl("ws://127.0.0.1:${server.localPort}/")
private val handshaken = CountDownLatch(1)
val clientCloseFrame = CountDownLatch(1)
val clientCloseCode = AtomicInteger(-1)
private var socket: Socket? = null
private var out: OutputStream? = null
private val thread =
thread(isDaemon = true, name = "tiny-relay") {
runCatching {
val s = server.accept()
socket = s
val input = s.getInputStream()
val output = s.getOutputStream()
out = output
handshake(input, output)
handshaken.countDown()
readFrames(input)
}
}
private fun handshake(
input: InputStream,
output: OutputStream,
) {
var key: String? = null
val line = StringBuilder()
while (true) {
val c = input.read()
check(c != -1) { "EOF during handshake" }
if (c == '\n'.code) {
val l = line.toString().trim()
if (l.isEmpty()) break
if (l.lowercase().startsWith("sec-websocket-key:")) key = l.substring(18).trim()
line.setLength(0)
} else if (c != '\r'.code) {
line.append(c.toChar())
}
}
val accept =
Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-1").digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()),
)
output.write(
(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: $accept\r\n\r\n"
).toByteArray(Charsets.ISO_8859_1),
)
output.flush()
}
/** Client frames are masked; decode enough to spot a CLOSE and read its status code. */
private fun readFrames(input: InputStream) {
while (true) {
val b0 = input.read()
if (b0 == -1) return
val b1 = input.read()
if (b1 == -1) return
val opcode = b0 and 0x0F
var len = b1 and 0x7F
if (len == 126) {
len = (input.read() shl 8) or input.read()
} else if (len == 127) {
len = 0
repeat(8) { len = (len shl 8) or input.read() }
}
val masked = (b1 and 0x80) != 0
val mask = if (masked) ByteArray(4) { input.read().toByte() } else ByteArray(4)
val payload = ByteArray(len) { i -> (input.read() xor mask[i % 4].toInt()).toByte() }
if (opcode == 0x8) {
if (len >= 2) {
clientCloseCode.set(((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF))
}
clientCloseFrame.countDown()
}
}
}
fun awaitClient() = handshaken.await(5, TimeUnit.SECONDS)
/** Server-initiated CLOSE, status 1000, unmasked as servers send it. The TCP session stays open. */
fun sendClose() {
val output = checkNotNull(out) { "no client yet" }
output.write(byteArrayOf(0x88.toByte(), 0x02, 0x03, 0xE8.toByte()))
output.flush()
}
override fun close() {
runCatching { socket?.close() }
runCatching { server.close() }
thread.join(2_000)
}
}
private class Recorder : WebSocketListener {
val opened = CountDownLatch(1)
val closed = CountDownLatch(1)
val closedCount = AtomicInteger(0)
val closedCode = AtomicInteger(-1)
val failure = AtomicReference<Throwable?>(null)
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) = opened.countDown()
override suspend fun onMessage(text: String) {}
override fun onClosed(
code: Int,
reason: String,
) {
closedCode.set(code)
closedCount.incrementAndGet()
closed.countDown()
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
failure.set(t)
}
}
@Test
fun `a relay initiated close is answered and reported as closed`() {
TinyRelay().use { relay ->
val recorder = Recorder()
val client = OkHttpClient()
val socket = OkHttpWebSocket(relay.url, { client }, recorder)
socket.connect()
assertTrue("relay never saw the client", relay.awaitClient())
assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS))
relay.sendClose()
assertTrue("client never answered the relay's CLOSE frame", relay.clientCloseFrame.await(5, TimeUnit.SECONDS))
assertEquals(1000, relay.clientCloseCode.get())
assertTrue("onClosed never fired", recorder.closed.await(5, TimeUnit.SECONDS))
assertEquals("the relay's status code is what gets reported", 1000, recorder.closedCode.get())
assertNull("a clean handshake is not a failure", recorder.failure.get())
// The session already ended; the usual teardown afterwards must not report it twice.
socket.disconnect()
assertEquals("one terminal report per session", 1, recorder.closedCount.get())
assertTrue("a closed socket needs a fresh dial", socket.needsReconnect())
client.dispatcher.executorService.shutdown()
}
}
@Test
fun `disconnect reports the session end once, synchronously, and drops what OkHttp says afterwards`() {
TinyRelay().use { relay ->
val recorder = Recorder()
val client = OkHttpClient()
val socket = OkHttpWebSocket(relay.url, { client }, recorder)
socket.connect()
assertTrue("relay never saw the client", relay.awaitClient())
assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS))
socket.disconnect()
// Reported before disconnect() returned: the relay client dials the replacement
// right after this call and must not hear from the old socket later.
assertEquals("disconnect() must report synchronously", 1, recorder.closedCount.get())
assertEquals(1000, recorder.closedCode.get())
assertTrue(socket.needsReconnect())
// OkHttp's own reaction to cancel() -- a failure on its reader thread -- and the relay's
// reaction to the dropped TCP session must both be swallowed.
Thread.sleep(500)
assertEquals("no second report", 1, recorder.closedCount.get())
assertNull("the cancel's failure must not surface", recorder.failure.get())
client.dispatcher.executorService.shutdown()
}
}
}
@@ -221,7 +221,9 @@ object BuzzAgentCommands {
BuzzAgentCommands::class.java.getResourceAsStream("/buzz-agent/$name")
?: error("bundled wrapper /buzz-agent/$name missing from the amy jar")
).use { input -> out.outputStream().use { input.copyTo(it) } }
out.setExecutable(true)
// The runner launches the wrapper via `sh -c <path>`, so a missing exec bit would only
// surface later as an opaque "permission denied" — fail here with the path instead.
check(out.setExecutable(true)) { "could not mark wrapper ${out.absolutePath} executable" }
return out.absolutePath
}
return extract("workflow-agent.sh") to extract("workflow-ship.sh")
@@ -86,6 +86,16 @@ class OkHttpClientFactory(
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
// per host at 5, which serializes feed loading. Raise the limits so the feed
// can parallelize downloads the way a browser does.
//
// Resist trimming these on intuition. `maxRequests` is effectively the thread
// ceiling (Dispatcher's executor is corePoolSize=0 / maxPoolSize=MAX_VALUE over
// a SynchronousQueue), which makes a lower number look free -- but blocked
// threads commit little, these hosts are HTTP/2 so concurrent calls to one host
// multiplex over a single connection rather than a handshake each, and
// `readyAsyncCalls` is strict FIFO with no priority. PrefetchFeedMedia enqueues
// notes BEFORE the user reaches them, so a tighter cap makes the image actually
// on screen queue behind those prefetches instead of starting straight away.
// Change these with a benchmark/ run, not a hunch.
private val dispatcher =
Dispatcher().apply {
if (!HttpClientEnvironment.isEmulator) {
@@ -138,16 +148,22 @@ class OkHttpClientFactory(
.addInterceptor(OnionLocationInterceptor(onionCache))
.build()
private var lastProxy: Proxy? = null
// No connection-pool eviction when the proxy changes. OkHttp's `Address` -- the
// pool's lookup key -- includes the proxy (`Address.equalsNonHost`), so a call
// is only ever handed a connection opened through the very same route. A
// connection left over from an old proxy is already unreachable and simply ages
// out of the pool; evicting was defensive, not load-bearing.
//
// It also cost more than it looked. `evictAll()` empties the ENTIRE shared pool,
// and this one factory mints both the proxied and the direct client (see
// [DualHttpClientManager]) -- `buildLocalSocksProxy` never returns null, so those
// two alternated a single "last proxy" field forever. Every rebuild read as a
// route change and dropped every warm connection the other client was using, on
// each network-state emission and each resubscribe.
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
if (proxy != lastProxy) {
rootClient.connectionPool.evictAll()
lastProxy = proxy
}
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
@@ -162,6 +178,13 @@ class OkHttpClientFactory(
.build()
}
/**
* Closes every idle pooled connection. Call only on a real proxy-route change (see
* [evictOnProxyRouteChange]) — connections on a dead route are already unreachable, so this
* is hygiene, not correctness, and it empties the pool BOTH clients share.
*/
fun evictPooledConnections() = rootClient.connectionPool.evictAll()
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
@@ -86,16 +86,22 @@ class OkHttpClientFactoryForRelays(
.addInterceptor(OnionLocationInterceptor(onionCache))
.build()
private var lastProxy: Proxy? = null
// No connection-pool eviction when the proxy changes. OkHttp's `Address` -- the
// pool's lookup key -- includes the proxy (`Address.equalsNonHost`), so a call
// is only ever handed a connection opened through the very same route. A
// connection left over from an old proxy is already unreachable and simply ages
// out of the pool; evicting was defensive, not load-bearing.
//
// It also cost more than it looked. `evictAll()` empties the ENTIRE shared pool,
// and this one factory mints both the proxied and the direct client (see
// [DualHttpClientManagerForRelays]) -- `buildLocalSocksProxy` never returns null, so those
// two alternated a single "last proxy" field forever. Every rebuild read as a
// route change and dropped every warm connection the other client was using, on
// each network-state emission and each resubscribe.
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
if (proxy != lastProxy) {
rootClient.connectionPool.evictAll()
lastProxy = proxy
}
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
@@ -108,6 +114,13 @@ class OkHttpClientFactoryForRelays(
.build()
}
/**
* Closes every idle pooled connection. Call only on a real proxy-route change (see
* [evictOnProxyRouteChange]) — connections on a dead route are already unreachable, so this
* is hygiene, not correctness, and it empties the pool BOTH clients share.
*/
fun evictPooledConnections() = rootClient.connectionPool.evictAll()
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
@@ -240,14 +240,6 @@ actual class SecureKeyStorage private actual constructor() {
}
}
/**
* Whether the macOS `security` CLI probe is used for strict lookups. Reads
* `os.name` by default; tests that wire [macSecurityLookup] set it to `true`
* so the probe path is exercised on every host, not only on a Mac (the
* vault test suite was red on Linux CI for exactly this reason).
*/
internal var isMacOs: () -> Boolean = { defaultIsMacOs() }
/**
* Test seam: overridable strategy for the strict macOS lookup. Production wires
* to [defaultMacSecurityLookup] which spawns `/usr/bin/security`. Tests replace
@@ -1015,7 +1007,7 @@ internal fun parseMacSecurityFindResult(
}
}
private fun defaultIsMacOs(): Boolean = System.getProperty("os.name").orEmpty().startsWith("Mac")
private fun isMacOs(): Boolean = System.getProperty("os.name").orEmpty().startsWith("Mac")
/**
* Production implementation: spawn `/usr/bin/security` and read exit code + streams.
@@ -375,9 +375,6 @@ class SecureKeyStorageVaultTest {
storage: SecureKeyStorage,
backend: CountingKeyring,
) {
// Force the mac probe path regardless of the host OS: the point of the
// fake is to exercise it, and CI runs this suite on Linux.
storage.isMacOs = { true }
storage.macSecurityLookup = { service, account ->
backend.store[service to account]
?.let { MacSecurityResult.Found(it) }
@@ -279,6 +279,8 @@
<string name="send_payment_source_cashu_wallet">Cashu peněženka</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_clink">Nabídka CLINK</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_amount">Částka</string>
<string name="send_payment_fixed_price">Cena stanovená nabídkou</string>
<string name="send_payment_receipt_section">Potvrzení o zapu</string>
@@ -311,6 +313,7 @@
<string name="secret_note_to_receiver">Tajná poznámka pro příjemce</string>
<string name="secret_note_to_receiver_placeholder">Moje skrytá zpráva</string>
<string name="secret_visible_text">Viditelná předpona</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Přidat k příspěvku</string>
<string name="article">Článek</string>
<string name="channel_name">Název kanálu</string>
@@ -324,6 +327,7 @@
<string name="my_name">Moje @tag jméno</string>
<string name="display_name">Zobrazované jméno</string>
<string name="my_display_name">Moje zobrazované jméno</string>
<string name="my_awesome_name">Ostrich McAwesome</string>
<string name="welcome">Vítejte Ostrich!</string>
<string name="username">Uživatelské jméno</string>
<string name="about_me">O mně</string>
@@ -439,6 +443,7 @@
<string name="dm_sender_reported_hide">Skrýt</string>
<string name="dm_sender_reported_subtitle">Někdo, koho sledujete, nahlásil tento účet</string>
<string name="dm_sender_reported_icon">Nahlášeno</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="error_loading_replies">Chyba při načítání odpovědí: </string>
<string name="try_again">Zkusit znovu</string>
<string name="notification_feed_is_empty">Zatím žádná oznámení.</string>
@@ -620,7 +625,11 @@
<string name="app_definition_implements">Implementuje</string>
<string name="app_definition_supported_nips">Podporované NIP</string>
<string name="app_definition_via">prostřednictvím %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_kind_app">Aplikace</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="workout">Trénink</string>
<string name="workout_title">Název</string>
<string name="workout_duration">Doba trvání</string>
@@ -645,7 +654,25 @@
<string name="workout_suggestion_connect_title">Sdílejte své tréninky</string>
<string name="workout_suggestion_connect_message">Nechte Amethyst číst dokončené tréninky z Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) a navrhnout příspěvek.</string>
<string name="workout_suggestion_connect_button">Připojit</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Z Health Connect</string>
<string name="workout_suggestion_connect_details">Co Amethyst čte</string>
<string name="health_connect_rationale_title">Health Connect a Amethyst</string>
<string name="health_connect_rationale_headline">Amethyst čte dokončené tréninky, aby za vás mohl předvyplnit příspěvek o tréninku.</string>
<string name="health_connect_rationale_intro">Amethyst je sociální klient sítě Nostr. Jeho sekce Tréninky vám umožňuje zveřejnit souhrn dokončeného tréninku na relaye Nostr, které si zvolíte, aby lidé, kteří vás sledují, viděli, co jste dělali. Místo ručního vypisování každého čísla může Amethyst načíst trénink, který vaše hodinky nebo fitness aplikace už uložily do Health Connect, a příspěvek předvyplnit. Předvyplněný příspěvek vždy uvidíte a sami rozhodnete, zda ho zveřejníte.</string>
<string name="health_connect_rationale_what_title">Co Amethyst čte a proč</string>
<string name="health_connect_rationale_exercise">Cvičení · jakou aktivitu jste dělali, kdy začala a jak dlouho trvala — to je samotný trénink a zároveň název a doba trvání příspěvku.</string>
<string name="health_connect_rationale_distance">Vzdálenost · jak daleko jste se dostali, zobrazená jako vzdálenost běhu, jízdy, chůze nebo plavání.</string>
<string name="health_connect_rationale_calories">Aktivní a celkové kalorie · energie spálená při tréninku. Aktivní kalorie se použijí, pokud je váš zdroj zaznamenává; celkové kalorie jsou náhradou pro zdroje, které zaznamenávají pouze celkovou energii.</string>
<string name="health_connect_rationale_heart_rate">Tepová frekvence · průměrná a maximální tepová frekvence během tréninku, běžné měřítko náročnosti.</string>
<string name="health_connect_rationale_steps">Kroky · počet kroků při běhu, chůzi nebo turistice.</string>
<string name="health_connect_rationale_elevation">Převýšení · kolik jste nastoupali, což odlišuje rovinatou jízdu od kopcovité.</string>
<string name="health_connect_rationale_limits_title">Co Amethyst nedělá</string>
<string name="health_connect_rationale_limit_window">Čte pouze tréninky dokončené za posledních 7 dní, a to jen když je otevřený editor Tréninků. Na pozadí nečte nikdy.</string>
<string name="health_connect_rationale_limit_write">Nikdy nic nezapisuje do Health Connect a nikdy nežádá o trasu vašeho cvičení, polohu ani jiný typ zdravotních údajů.</string>
<string name="health_connect_rationale_limit_publish">Nic neopustí váš telefon, dokud sami neklepnete na návrh a příspěvek nezveřejníte. Amethyst nemá žádný server: příspěvek jde na relaye Nostr, které jste nastavili.</string>
<string name="health_connect_rationale_limit_optional">Celá funkce je volitelná. Vypnete ji v Nastavení → Nastavení editoru, nebo kdykoli odeberete oprávnění v Health Connect — zbytek Amethystu funguje dál.</string>
<string name="health_connect_rationale_privacy_policy">Přečíst si celé zásady ochrany soukromí</string>
<string name="nsite_none_found">Zatím nenalezeny žádné nSites.</string>
<string name="browser_reload">Obnovit</string>
<string name="browser_unsupported">Integrovaný prohlížeč vyžaduje Android 11 nebo novější.</string>
@@ -732,7 +759,9 @@
<string name="nip46_signer_act_decrypted">Dešifrovala zprávu</string>
<string name="nip46_signer_act_shared_pubkey">Sdílela váš veřejný klíč</string>
<string name="nip46_signer_act_connected">Připojeno</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Vypsala relaye</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_scan_caption">Naskenujte pro připojení aplikace k vašemu klíči</string>
<string name="nip46_signer_scan_connect">Naskenovat kód</string>
<string name="nip46_signer_paste_link">Místo toho vložte odkaz</string>
@@ -780,6 +809,7 @@
<string name="relay_auth_no_recent">Zatím vás o přihlášení nepožádalo žádné relé.</string>
<string name="relay_auth_ago">před %1$s</string>
<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>
<string name="nip82_section_about">O aplikaci</string>
<string name="nip82_section_platforms">Platformy</string>
@@ -826,6 +856,7 @@
<string name="nest_leave_host_close">Zavřít místnost</string>
<string name="nest_leave_host_just_leave">Jen odejít</string>
<string name="nest_live_chip">ŽIVĚ</string>
<string name="nest_tab_chat">Chat</string>
<string name="nest_tab_audience">Publikum</string>
<string name="nest_tab_hands">Ruce</string>
<string name="nest_audience_empty">V publiku zatím nikdo není.</string>
@@ -901,15 +932,20 @@
<string name="podcast_completed">Dokončeno</string>
<string name="podcast_support_show">Podpořte pořad</string>
<string name="podcast_by_author">od %1$s</string>
<string name="podcast_season_episode">S%1$d · E%2$d</string>
<string name="podcast_episode_number">Ep %1$d</string>
<string name="podcast_season">Série %1$d</string>
<string name="podcast_video">Video</string>
<string name="podcast_transcript">Přepis</string>
<string name="podcast_chapters">Kapitoly</string>
<string name="podcast_hosts_and_guests">Moderátoři a hosté</string>
<string name="podcast_top_supporters">Nejlepší podporovatelé</string>
<string name="podcast_role_host">Moderátor</string>
<string name="podcast_role_cohost">Spolumoderátor</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_author_verified">Ověřený autor</string>
<string name="podcast_value_stream">Streamovat sats</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_value_stream_hint">Automaticky posílá hodnotu, když posloucháte.</string>
<string name="podcast_value_streamed_total">V této relaci odesláno %1$d sats</string>
<string name="podcast_episode_title_label">Název</string>
@@ -982,6 +1018,7 @@
<string name="add_hashtag_label">Přidat hashtag</string>
<string name="add_hashtag_label_title">Přidat hashtag</string>
<string name="add_hashtag_label_explainer">Veřejně označte tento příspěvek hashtagem (štítek NIP-32). Lidé, kteří vás sledují, jej uvidí ve feedu daného hashtagu.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Přidat</string>
<string name="hashtag_label_added_by">přidal(a)</string>
<string name="pinned_notes">Připnuté poznámky</string>
@@ -1040,6 +1077,7 @@
<string name="quick_zap_amounts_explainer">Zobrazí se při stisknutí tlačítka zap. Klepnutím na částku ji odeberete. Pokud ponecháte prázdné, otevře se pokaždé dialog pro zadání částky.</string>
<string name="send_onchain_instead">Odeslat on-chain</string>
<string name="reload_mint_title">Dobít mint</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="reload_mint_topup_label">Částka dobití</string>
<string name="reload_mint_section_to">Mint k dobití</string>
<string name="reload_mint_section_from">Prostředky z</string>
@@ -1251,6 +1289,7 @@
<string name="show_sensitive_content_explainer">Zobrazí varovnou zprávu, když ji autor příspěvku označil jako citlivý</string>
<string name="max_hashtag_limit_title">Max. hashtagů na příspěvek</string>
<string name="max_hashtag_limit_explainer">Skryje příspěvky s více hashtagy, než je tento limit. Nastavte 0 pro deaktivaci</string>
<string name="security_unlimited"></string>
<string name="new_reaction_symbol">Nový symbol reakce</string>
<string name="zapraiser">Zap-sběr</string>
<string name="zapraiser_explainer">Přidá cílovou částku sats pro tento příspěvek. Podporované klienty ji mohou zobrazovat jako ukazatel postupu k podněcování darování</string>
@@ -1282,6 +1321,7 @@
<string name="all_languages">Všechny jazyky</string>
<string name="posting_policy">Politika příspěvků</string>
<string name="terms_and_conditions">Podmínky &amp; ujednání</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Chyby a upozornění z tohoto relé</string>
<string name="relay_monitor_reports">Zprávy monitoru relé</string>
<string name="relay_active_subscriptions">Aktivní odběry</string>
@@ -1292,6 +1332,7 @@
<string name="relay_filter_ids">%1$d ID</string>
<string name="relay_filter_since">od %1$s</string>
<string name="relay_filter_until">do %1$s</string>
<string name="relay_filter_limit">limit %1$d</string>
<string name="minimum_prefix">Min. předpona</string>
<string name="maximum_event_tags">Max. značek události</string>
<string name="max_content_length">Max. délka obsahu</string>
@@ -1312,10 +1353,12 @@
<string name="max_filters_per_sub">Max. filtrů na odběr</string>
<string name="max_limit_events_returning">Max. limit (vracené události)</string>
<string name="max_subid_length">Max. délka SubID</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_redeem">Vyměnit</string>
<string name="cashu_copy_token">Kopírovat token</string>
<string name="quick_actions_open_in_another_app">Otevřít v jiné aplikaci</string>
<string name="live_stream_live_tag">ŽIVĚ</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">UKONČENO</string>
<string name="live_stream_planned_tag">PLÁNOVÁNO</string>
<string name="live_stream_private_tag">SOUKROMÉ</string>
@@ -1441,6 +1484,7 @@
<string name="relay_group_badge_private">Soukromá</string>
<string name="relay_group_badge_invite_only">Pouze na pozvání</string>
<string name="relay_group_badge_live">ŽIVĚ</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Zadejte platnou URL relay (wss://…).</string>
<string name="relay_group_field_name">Název</string>
<string name="relay_group_field_about">Popis</string>
@@ -1533,6 +1577,7 @@
<string name="poll_zap_value_min_max_explainer">Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky.</string>
<string name="error_dialog_talk_to_user">Poslat zprávu uživateli</string>
<string name="error_dialog_talk_to_user_name">Zpráva %1$s</string>
<string name="error_dialog_button_ok">OK</string>
<string name="zap_split_title">Rozdělit a přeposlat Zaps</string>
<string name="zap_split_explainer">Podporované klienty budou Zapy rozdělovat a přeposílat uživatelům přidaným zde místo vám</string>
<string name="zap_split_search_and_add_user">Hledat a přidat uživatele</string>
@@ -1566,6 +1611,7 @@
<string name="send_the_seller_a_message">Poslat prodejci zprávu</string>
<string name="hi_there_is_this_still_available">Ahoj, je to stále k dispozici?</string>
<string name="classifieds_title">Název</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Stav</string>
<string name="classifieds_category">Kategorie</string>
<string name="classifieds_price">Cena (v Sats)</string>
@@ -1594,6 +1640,7 @@
<string name="wallet_send">Odeslat</string>
<string name="wallet_receive">Přijmout</string>
<string name="wallet_transactions">Transakce</string>
<string name="wallet_sats">sats</string>
<string name="wallet_paste_invoice">Vložte fakturu BOLT-11</string>
<string name="wallet_pay">Zaplatit</string>
<string name="wallet_payment_success">Platba úspěšná</string>
@@ -1722,7 +1769,9 @@
<string name="cashu_getting_quote">Žádám mint o nabídku…</string>
<string name="cashu_verify">Ověřit</string>
<string name="cashu_mint_reachable">✓ Mint je dostupný</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Nelze se připojit k mintu: %1$s</string>
<string name="nutzap">Nutzap</string>
<string name="cashu_create_token">Vytvořit token</string>
<string name="cashu_redeem_button">Uplatnit</string>
<string name="cashu_done">Hotovo</string>
@@ -1757,9 +1806,11 @@
<string name="onchain_send_recipient_placeholder">jméno, NIP-05 nebo npub</string>
<string name="onchain_send_change_recipient">Změnit příjemce</string>
<string name="onchain_send_amount">Částka</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="onchain_send_min_warning">Minimální on-chain zap je %1$s sats — menší částky pohltí poplatky těžařům. Použij raději Lightning zap.</string>
<string name="onchain_send_comment_label">Komentář (nepovinné)</string>
<string name="onchain_send_priority">Priorita</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_loading_fees">Načítání odhadů poplatků…</string>
<string name="onchain_send_button_amount">Odeslat %1$s sats</string>
<string name="onchain_send_dont_split">Nerozdělovat — zaplatit jednomu příjemci</string>
@@ -1768,6 +1819,7 @@
<string name="onchain_send_result_transaction">Transakce</string>
<string name="onchain_send_result_fee">Poplatek</string>
<string name="onchain_send_result_change">Drobné</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_done">Hotovo</string>
<string name="onchain_send_failed_at">Selhalo při: %1$s</string>
<string name="onchain_send_broadcast_no_receipt">Platba byla odeslána (tx %1$s), ale potvrzení nebylo zveřejněno.</string>
@@ -1890,9 +1942,11 @@
<string name="reactions_settings_reorder">Změnit pořadí</string>
<string name="reactions_settings_reply">Odpovědět</string>
<string name="reactions_settings_reply_description">Odpovědět na tuto poznámku</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost_description">Repostovat nebo citovat tuto poznámku</string>
<string name="reactions_settings_like">To se mi líbí</string>
<string name="reactions_settings_like_description">Reagovat na tuto poznámku pomocí emoji</string>
<string name="reactions_settings_zap">Zap</string>
<string name="reactions_settings_zap_description">Poslat lightning platbu autorovi</string>
<string name="reactions_settings_share">Sdílet</string>
<string name="reactions_settings_share_description">Sdílet tuto poznámku externě</string>
@@ -1964,6 +2018,7 @@
<string name="buzz_system_message_deleted">%1$s smazal zprávu</string>
<string name="buzz_system_message_deleted_reason">%1$s smazal zprávu: %2$s</string>
<string name="buzz_system_dm_created">%1$s zahájil tuto konverzaci</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_huddle_started">🔊 %1$s zahájil huddle</string>
<string name="buzz_huddle_joined">🔊 %1$s se připojil k huddlu</string>
<string name="buzz_huddle_left">🔊 %1$s opustil huddle</string>
@@ -2011,6 +2066,7 @@
<string name="buzz_invite_title">Pozvánka do pracovního prostoru</string>
<string name="buzz_invite_heading">Připojit se k tomuto pracovnímu prostoru</string>
<string name="buzz_invite_workspace">Pracovní prostor</string>
<string name="buzz_invite_role">Role</string>
<string name="buzz_invite_body">Pracovní prostor se otevře v zabezpečeném prohlížeči v aplikaci, kde si projdete podmínky a dokončíte připojení. Přihlásí vás vaším klíčem Amethyst — bez hesla.</string>
<string name="buzz_invite_continue">Pokračovat v prohlížeči</string>
<string name="buzz_invite_invalid">Toto nevypadá jako platný odkaz s pozvánkou Buzz.</string>
@@ -2031,6 +2087,7 @@
<string name="buzz_invite_share">Sdílet</string>
<string name="buzz_invite_copy">Kopírovat</string>
<string name="buzz_invite_error_title">Pozvánku se nepodařilo vytvořit</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_dm_section_empty">Zatím žádné konverzace</string>
<string name="chat_delivery_details_title">Doručení zprávy</string>
<string name="close">Zavřít</string>
@@ -2079,6 +2136,7 @@
<string name="forked_from">Rozštěpováno od</string>
<string name="git_clone_address">Klon:</string>
<string name="git_branch">Větev</string>
<string name="git_commit">Commit</string>
<string name="git_merge_base">Základ sloučení</string>
<string name="git_pr_update_description">Pull request byl aktualizován na nový commit.</string>
<string name="git_repo_tab_issues">Úkoly</string>
@@ -2115,6 +2173,7 @@
<string name="git_repo_commits">Commity</string>
<string name="git_repo_no_commits">Není k dispozici žádná historie commitů.</string>
<string name="git_repo_copy_file">Kopírovat obsah souboru</string>
<string name="git_repo_plain_text">Text</string>
<string name="git_new_issue_button">Nový</string>
<string name="git_new_issue_title">Nový problém</string>
<string name="git_new_issue_subject">Název</string>
@@ -2177,6 +2236,7 @@
<string name="dvm_home_status_payment_required">Tento algoritmus zdroje vyžaduje platbu</string>
<string name="dvm_home_status_error">Algoritmus zdroje vrátil chybu</string>
<string name="dvm_home_retry">Zkusit znovu</string>
<string name="dvm_offline">Offline</string>
<string name="dvm_offline_banner">Tento algoritmus zdroje v poslední době neposlal signál aktivity a může být nedostupný</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="group_relay">Relé chatu</string>
@@ -2204,6 +2264,7 @@
<string name="search_filters_section_source">Zdroj</string>
<string name="search_filters_section_sort">Seřadit podle</string>
<string name="search_filters_open">Filtry</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="search_languages">Hledat jazyky</string>
<string name="add_language">Přidat jazyk</string>
<string name="add_language_pair">Přidat jazykový pár</string>
@@ -2263,6 +2324,7 @@
<string name="timeout">Časový limit vypršel</string>
<string name="retrying">Opakuje se…</string>
<string name="dismiss">Zavřít</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Odesílání</string>
<string name="broadcasting_name">Odesílání %1$s</string>
<string name="broadcasting_number_events">Odesílání %1$d událostí…</string>
@@ -2305,6 +2367,7 @@
<string name="profile_to_import_from">Profil, ze kterého importovat</string>
<string name="name_search_npub1_alice_example_com">hledání jména, npub1…, alice@example.com</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Podporuje npub, NIP-05, hex a Namecoin (.bit / d/ / id/)</string>
<string name="tip">Tip</string>
<string name="accounts_found">Nalezeno účtů: %1$d</string>
<string name="num_selected">Vybráno: %1$d</string>
<string name="follow_accounts">Sledovat %1$d účtů</string>
@@ -2366,6 +2429,7 @@
<string name="cashu_move_coins_withdraw_ln">Vybrat přes Lightning…</string>
<string name="cashu_move_coins_export_token">Exportovat jako Cashu token…</string>
<string name="cashu_move_coins_working">Přesouvám mince…</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Připojování</string>
<string name="event_sync_status_downloading">Stahování</string>
<string name="event_sync_status_error">Chyba</string>
@@ -2375,6 +2439,7 @@
<string name="profile_actions_dialog_title">Akce profilu</string>
<string name="media_actions_dialog_title">Akce médií</string>
<string name="playback_actions_dialog_title">Přehrávání</string>
<string name="video_quality_auto">Auto</string>
<string name="edit_nickname">Upravit přezdívku</string>
<string name="nickname_private">Viditelné pouze pro vás</string>
<string name="cast_to_device_dialog_title">Odeslat do…</string>
@@ -2389,6 +2454,7 @@
<string name="hls_content_warning_reason_placeholder">Důvod (nepovinné)</string>
<string name="hls_codec_label">Kodek</string>
<string name="hls_codec_h265">H.265 (lepší komprese)</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_fallback_notice">H.265 není na tomto zařízení dostupný — používá se H.264.</string>
<string name="hls_renditions_label">Rozlišení</string>
<string name="hls_renditions_source_format">Zdrojové rozlišení: %1$d×%2$d</string>
@@ -2426,11 +2492,14 @@
<string name="event_sync_date_filter_until">Do</string>
<string name="event_sync_date_filter_now">Nyní</string>
<string name="event_sync_date_filter_all_time">Vždy</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://example.com</string>
<string name="web_bookmark_title_label">Název</string>
<string name="web_bookmark_title_placeholder">Název záložky</string>
<string name="web_bookmark_description_label">Popis</string>
<string name="web_bookmark_description_placeholder">Krátký popis</string>
<string name="web_bookmark_tags_label">Tagy (oddělené čárkou)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Uložit</string>
<string name="web_bookmark_delete">Smazat</string>
<string name="web_bookmark_delete_confirm">Smazat tuto webovou záložku?</string>
@@ -2439,12 +2508,15 @@
<string name="fundraiser_ends">Končí %1$s</string>
<string name="fundraiser_onchain_donation">On-chain dar</string>
<string name="goal_amount_label">Cílová částka (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Popište svůj cíl</string>
<string name="goal_description_placeholder">Na co sbíráte prostředky?</string>
<string name="goal_summary_label">Krátký souhrn</string>
<string name="goal_summary_placeholder">Stručný popis zobrazený v náhledech</string>
<string name="goal_image_label">URL obrázku (volitelné)</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="goal_website_label">URL webu (volitelné)</string>
<string name="goal_website_placeholder">https://example.com</string>
<string name="goal_set_deadline">Nastavit termín</string>
<string name="request_to_vanish_description">Požádejte relé o trvalé smazání všech vašich dat do vybraného data. Tato akce je založena na NIP-62 a v některých jurisdikcích je právně závazná.</string>
<string name="vanish_target_relay">Cílové relé</string>
@@ -2549,6 +2621,7 @@
<string name="ai_tone_elaborate">Rozvést</string>
<string name="ai_tone_friendly">Přátelský</string>
<string name="ai_tone_professional">Profesionální</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="emoji_packs_title">Emoji balíčky</string>
<string name="emoji_pack_management_title">Přidat do seznamu emoji</string>
<string name="add_to_emoji_list">Přidat do mého seznamu emoji</string>
@@ -2577,6 +2650,7 @@
<string name="emoji_pack_actions_dialog_title">Akce emoji balíčku</string>
<string name="emoji_private_toggle">Soukromé</string>
<string name="emoji_private_badge">Soukromé emoji</string>
<string name="gif">Gif</string>
<string name="community_rules_violation_icon">Porušení pravidel komunity</string>
<string name="community_rules_violation_author_denied">Jsi na seznamu blokovaných této komunity. Příspěvky nebudou přijaty.</string>
<string name="community_rules_violation_kind_not_allowed">Tato komunita nepřijímá události typu %1$d.</string>
@@ -2597,6 +2671,7 @@
<string name="marmot_disband_group_confirm">Rozpustit „%1$s“ pro všechny? Konverzace skončí pro každého člena a nelze ji znovu otevřít — bylo by nutné vytvořit novou skupinu.</string>
<string name="marmot_disband_group_action">Rozpustit</string>
<string name="marmot_avatar_url">Odkaz na avatar</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">Odkaz https, který načte každý člen. Ponechte prázdné, chcete-li místo toho použít nahraný obrázek.</string>
<string name="marmot_legacy_group_no_avatar_url">Tato skupina byla vytvořena dříve, než byly podporovány avatary z odkazu, takže může používat pouze nahraný obrázek. Stávající skupiny nelze upgradovat — pro použití odkazu vytvořte novou skupinu.</string>
<string name="marmot_enable_encrypted_media">Používat šifrované přílohy</string>
@@ -2604,6 +2679,7 @@
<string name="marmot_legacy_group_no_disband">Tato skupina byla vytvořena dříve, než bylo podporováno rozpuštění, a nelze ji rozpustit. Stále ji můžete opustit nebo odebrat všechny ostatní členy. Stávající skupiny nelze upgradovat.</string>
<string name="marmot_group_default_name">Skupina Marmot</string>
<string name="marmot_group_fallback_name">Skupina %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="marmot_tab_known">Známé</string>
<string name="marmot_tab_known_count">Známé (%1$d)</string>
<string name="marmot_tab_new_requests">Nové žádosti</string>
@@ -2683,6 +2759,7 @@
<string name="buzz_workflow_gate_needs_you">Potřebuje vaše schválení</string>
<string name="buzz_workflow_gate_awaiting">Čeká na schválení</string>
<string name="buzz_workflow_no_description">(bez popisu)</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_by">od</string>
<string name="buzz_workflow_waiting_on">čeká na</string>
<string name="buzz_workflow_readonly_approver">Jste schvalovatel, ale toto přihlášení nemůže rozhodnutí podepsat.</string>
@@ -2710,6 +2787,7 @@
<string name="buzz_workflow_no_defs_hint">Zatím žádná workflow. Otevřete nabídku výše, zvolte „Nová definice…“, vytvořte ho a pak spusťte.</string>
<string name="buzz_workflow_task_label">Co má udělat?</string>
<string name="buzz_workflow_trigger_run">Spustit běh</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="buzz_workflow_picker_empty">Zatím nejsou definována žádná workflow</string>
<string name="buzz_workflow_picker_choose">Vyberte workflow</string>
<string name="buzz_workflow_new_definition">Nová definice…</string>
@@ -2759,83 +2837,5 @@
<string name="buzz_persona_publishing">Publikování…</string>
<string name="buzz_persona_publish">Publikovat personu</string>
<string name="profile_card_follows_you">Sleduje vás</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_invite_role">Role</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="dvm_offline">Offline</string>
<string name="error_dialog_button_ok">OK</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="gif">Gif</string>
<string name="git_commit">Commit</string>
<string name="git_repo_plain_text">Text</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="goal_website_placeholder">https://example.com</string>
<string name="hls_codec_h264">H.264</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="my_awesome_name">Ostrich McAwesome</string>
<string name="nest_tab_chat">Chat</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip82_version_label">v%1$s</string>
<string name="not_available_acronym">N/A</string>
<string name="nutzap">Nutzap</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="platform_web">Web</string>
<string name="podcast_episode_number">Ep %1$d</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_season_episode">S%1$d · E%2$d</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_video">Video</string>
<string name="profile_card_bot">Bot</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_zap">Zap</string>
<string name="relay_filter_limit">limit %1$d</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="security_unlimited"></string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="share_of">%1$d/%2$d</string>
<string name="tip">Tip</string>
<string name="video_quality_auto">Auto</string>
<string name="wallet_sats">sats</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://example.com</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="health_connect_rationale_headline">Amethyst čte dokončené tréninky, aby za vás mohl předvyplnit příspěvek o tréninku.</string>
<string name="health_connect_rationale_title">Health Connect a Amethyst</string>
<string name="health_connect_rationale_intro">Amethyst je sociální klient sítě Nostr. Jeho sekce Tréninky vám umožňuje zveřejnit souhrn dokončeného tréninku na relaye Nostr, které si zvolíte, aby lidé, kteří vás sledují, viděli, co jste dělali. Místo ručního vypisování každého čísla může Amethyst načíst trénink, který vaše hodinky nebo fitness aplikace už uložily do Health Connect, a příspěvek předvyplnit. Předvyplněný příspěvek vždy uvidíte a sami rozhodnete, zda ho zveřejníte.</string>
<string name="health_connect_rationale_what_title">Co Amethyst čte a proč</string>
<string name="health_connect_rationale_exercise">Cvičení · jakou aktivitu jste dělali, kdy začala a jak dlouho trvala — to je samotný trénink a zároveň název a doba trvání příspěvku.</string>
<string name="health_connect_rationale_steps">Kroky · počet kroků při běhu, chůzi nebo turistice.</string>
<string name="health_connect_rationale_distance">Vzdálenost · jak daleko jste se dostali, zobrazená jako vzdálenost běhu, jízdy, chůze nebo plavání.</string>
<string name="health_connect_rationale_calories">Aktivní a celkové kalorie · energie spálená při tréninku. Aktivní kalorie se použijí, pokud je váš zdroj zaznamenává; celkové kalorie jsou náhradou pro zdroje, které zaznamenávají pouze celkovou energii.</string>
<string name="health_connect_rationale_elevation">Převýšení · kolik jste nastoupali, což odlišuje rovinatou jízdu od kopcovité.</string>
<string name="health_connect_rationale_heart_rate">Tepová frekvence · průměrná a maximální tepová frekvence během tréninku, běžné měřítko náročnosti.</string>
<string name="health_connect_rationale_limits_title">Co Amethyst nedělá</string>
<string name="health_connect_rationale_limit_window">Čte pouze tréninky dokončené za posledních 7 dní, a to jen když je otevřený editor Tréninků. Na pozadí nečte nikdy.</string>
<string name="health_connect_rationale_limit_publish">Nic neopustí váš telefon, dokud sami neklepnete na návrh a příspěvek nezveřejníte. Amethyst nemá žádný server: příspěvek jde na relaye Nostr, které jste nastavili.</string>
<string name="health_connect_rationale_limit_write">Nikdy nic nezapisuje do Health Connect a nikdy nežádá o trasu vašeho cvičení, polohu ani jiný typ zdravotních údajů.</string>
<string name="health_connect_rationale_limit_optional">Celá funkce je volitelná. Vypnete ji v Nastavení → Nastavení editoru, nebo kdykoli odeberete oprávnění v Health Connect — zbytek Amethystu funguje dál.</string>
<string name="health_connect_rationale_privacy_policy">Přečíst si celé zásady ochrany soukromí</string>
<string name="workout_suggestion_connect_details">Co Amethyst čte</string>
</resources>
@@ -195,6 +195,7 @@
<string name="post_was_hidden">Dieser Beitrag wurde ausgeblendet, weil er Ihre ausgeblendeten Benutzer oder Wörter erwähnt</string>
<string name="post_was_flagged_as_inappropriate_by">Der Beitrag wurde als unangemessen gekennzeichnet von</string>
<string name="post_not_found">Ereignis wird geladen oder kann nicht in deiner Relay-Liste gefunden werden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanalbild</string>
<string name="referenced_event_not_found">Referenziertes Ereignis nicht gefunden</string>
<string name="could_not_decrypt_the_message">Nachricht konnte nicht entschlüsselt werden</string>
@@ -224,6 +225,7 @@
<string name="report_impersonation">Identitätsbetrug melden</string>
<string name="report_illegal_behaviour">Illegales Verhalten melden</string>
<string name="report_malware">Malware melden</string>
<string name="malware">Malware</string>
<string name="chat_zap_amount_suffix">hat %1$s Sats gezappt</string>
<string name="chat_zap_anonymous">Anonym</string>
<string name="chat_raid_is_raiding">raidet</string>
@@ -231,6 +233,7 @@
<string name="edited">bearbeitet</string>
<string name="original">Original</string>
<string name="quote">Zitat</string>
<string name="fork">Fork</string>
<string name="new_amount_in_sats">Neuer Betrag in Sats</string>
<string name="and"> und </string>
<string name="following"> Gefolgt</string>
@@ -248,6 +251,7 @@
<string name="clink_offer_amount_sats">Betrag (Sats)</string>
<string name="clink_offer_amount_range">Erlaubter Bereich: %1$s%2$s Sats</string>
<string name="clink_offer_label">CLINK-Zahlungsanfrage (noffer)</string>
<string name="clink_budget_set">Budget</string>
<string name="clink_budget_title">Ausgabenbudget</string>
<string name="clink_budget_amount_sats">Betrag (Sats)</string>
<string name="clink_budget_request">Anfordern</string>
@@ -263,8 +267,10 @@
<string name="send_payment_source_external">Andere Wallet-App</string>
<string name="send_payment_source_onchain_wallet">On-Chain-Wallet</string>
<string name="send_payment_source_cashu_wallet">Cashu-Wallet</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_clink">CLINK-Zahlungsanfrage</string>
<string name="send_payment_method_onchain">On-Chain</string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_amount">Betrag</string>
<string name="send_payment_fixed_price">Preis durch das Angebot festgelegt</string>
<string name="send_payment_receipt_section">Zap-Quittung</string>
@@ -297,6 +303,7 @@
<string name="secret_note_to_receiver">Geheime Notiz an Empfänger</string>
<string name="secret_note_to_receiver_placeholder">Meine versteckte Nachricht</string>
<string name="secret_visible_text">Sichtbares Präfix</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Zum Beitrag hinzufügen</string>
<string name="article">Artikel</string>
<string name="channel_name">Kanalname</string>
@@ -310,6 +317,7 @@
<string name="my_name">Mein @tag-Name</string>
<string name="display_name">Anzeigename</string>
<string name="my_display_name">Mein Anzeigename</string>
<string name="my_awesome_name">Ostrich McAwesome</string>
<string name="welcome">Willkommen Ostrich!</string>
<string name="username">Benutzername</string>
<string name="about_me">Über mich</string>
@@ -333,6 +341,7 @@
<string name="user_suggestion_in_this_chat">In diesem Chat</string>
<string name="edits_the_channel_metadata">Bearbeitet die Kanalmetadaten</string>
<string name="join">Beitreten</string>
<string name="feed">Feed</string>
<string name="mod_queue">Mod-Warteschlange</string>
<string name="notes">Beiträge</string>
<string name="replies">Antworten</string>
@@ -340,6 +349,8 @@
<string name="gallery">Galerie</string>
<string name="reports">Meldungen</string>
<string name="number_reports">%1$s Meldungen</string>
<string name="relays"> Relays</string>
<string name="website">Website</string>
<string name="lightning_address">Lightning-Adresse</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Kopiert die Nsec-ID (Ihr Passwort) zur Sicherung in die Zwischenablage</string>
<string name="send_a_direct_message">Sende eine Direktnachricht</string>
@@ -382,6 +393,7 @@
<string name="concord_edit_banner_hint">Ein Banner hinzufügen</string>
<string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string>
<string name="concord_dissolved_read_only">Diese Community wurde aufgelöst und ist jetzt schreibgeschützt. Du kannst ihren Verlauf weiterhin lesen, aber es können keine neuen Nachrichten mehr gepostet werden.</string>
<string name="concord_create_name">Name</string>
<string name="concord_create_about">Über (optional)</string>
<string name="concord_ban_user">Sperren</string>
<string name="concord_ban_user_title">Aus dieser Community sperren?</string>
@@ -405,9 +417,11 @@
<string name="concord_invite_card_subtitle">Concord-Community-Einladung</string>
<string name="concord_invite_naddr_label">Concord-Einladung (öffne den vollständigen Einladungslink zum Beitreten)</string>
<string name="concord_view_mode_title">Concord-Community-Anzeige</string>
<string name="concord_view_inline">Inline</string>
<string name="concord_view_grouped">Nach Community</string>
<string name="concord_view_inline_desc">Jeden Kanal als eigene Unterhaltung anzeigen, vermischt mit deinen Chats.</string>
<string name="concord_view_grouped_desc">Die Kanäle jeder Community zu einer einzelnen Zeile zusammenfassen, platziert bei ihrer neuesten Nachricht.</string>
<string name="chat_minichat_title">Thread</string>
<string name="chat_send_image_title">Bild senden</string>
<string name="chats_history_proto_nip17">verschlüsselt</string>
<string name="chats_history_proto_nip04">veraltet</string>
@@ -419,6 +433,7 @@
<string name="dm_sender_reported_hide">Ausblenden</string>
<string name="dm_sender_reported_subtitle">Jemand, dem du folgst, hat dieses Konto gemeldet</string>
<string name="dm_sender_reported_icon">Gemeldet</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="error_loading_replies">Fehler beim Laden der Antworten: </string>
<string name="try_again">Erneut versuchen</string>
<string name="notification_feed_is_empty">Noch keine Benachrichtigungen.</string>
@@ -564,6 +579,7 @@
<string name="accept_badge">Annehmen</string>
<string name="reject_badge">Ablehnen</string>
<string name="unaccept_badge">Aus Profil entfernen</string>
<string name="badge_name_label">Name</string>
<string name="badge_name_placeholder">Lesbarer Name des Abzeichens</string>
<string name="badge_description_label">Beschreibung</string>
<string name="badge_description_placeholder">Warum wird dieses Abzeichen verliehen?</string>
@@ -578,6 +594,8 @@
<string name="profile_badges_header">Abzeichen · %1$d</string>
<string name="profile_badges_description">Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.</string>
<string name="profile_badges_empty">Du hast noch keine Abzeichen erhalten.</string>
<string name="profile_apps_header">Apps · %1$d</string>
<string name="profile_apps_header_empty">Apps</string>
<string name="profile_apps_empty_hint">Empfiehl die Nostr-Apps, die du nutzt, damit andere sie entdecken können.</string>
<string name="profile_app_recommendations_title">Empfohlene Apps</string>
<string name="profile_app_recommendations_description">Wähle aus, welche Nostr-Apps du öffentlich empfiehlst. Empfehlungen erscheinen in deinem Profil und helfen anderen, Apps für Inhalte zu finden, die dieser Client nicht öffnen kann.</string>
@@ -597,6 +615,12 @@
<string name="app_definition_implements">Implementiert</string>
<string name="app_definition_supported_nips">Unterstützte NIPs</string>
<string name="app_definition_via">über %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_kind_app">App</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="workout">Workout</string>
<string name="workout_title">Titel</string>
<string name="workout_duration">Dauer</string>
<string name="workout_distance">Distanz</string>
@@ -620,7 +644,25 @@
<string name="workout_suggestion_connect_title">Teile deine Workouts</string>
<string name="workout_suggestion_connect_message">Lass Amethyst abgeschlossene Workouts aus Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) lesen und einen Beitrag vorschlagen.</string>
<string name="workout_suggestion_connect_button">Verbinden</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Aus Health Connect</string>
<string name="workout_suggestion_connect_details">Was Amethyst liest</string>
<string name="health_connect_rationale_title">Health Connect und Amethyst</string>
<string name="health_connect_rationale_headline">Amethyst liest abgeschlossene Workouts, um einen Workout-Beitrag für dich vorauszufüllen.</string>
<string name="health_connect_rationale_intro">Amethyst ist ein sozialer Nostr-Client. Im Bereich Workouts kannst du eine Zusammenfassung eines abgeschlossenen Workouts an die Nostr-Relays deiner Wahl veröffentlichen, damit die Leute, die dir folgen, sehen können, was du gemacht hast. Statt jede Zahl von Hand einzutippen, kann Amethyst das Workout lesen, das deine Uhr oder Fitness-App bereits in Health Connect gespeichert hat, und den Beitrag vorausfüllen. Du siehst den vorausgefüllten Beitrag immer und entscheidest, ob du ihn veröffentlichst.</string>
<string name="health_connect_rationale_what_title">Was Amethyst liest und warum</string>
<string name="health_connect_rationale_exercise">Übung · welche Aktivität du gemacht hast, wann sie begann und wie lange sie dauerte — das ist das Workout selbst sowie Titel und Dauer des Beitrags.</string>
<string name="health_connect_rationale_distance">Distanz · wie weit du gekommen bist, angezeigt als Distanz des Laufs, der Fahrt, des Spaziergangs oder des Schwimmens.</string>
<string name="health_connect_rationale_calories">Aktive und gesamte Kalorien · die beim Workout verbrannte Energie. Aktive Kalorien werden verwendet, wenn deine Quelle sie aufzeichnet; gesamte Kalorien sind der Ersatz für Quellen, die nur die Gesamtenergie aufzeichnen.</string>
<string name="health_connect_rationale_heart_rate">Herzfrequenz · die durchschnittliche und maximale Herzfrequenz während des Workouts, das übliche Maß für die Anstrengung.</string>
<string name="health_connect_rationale_steps">Schritte · die Schrittzahl eines Laufs, Spaziergangs oder einer Wanderung.</string>
<string name="health_connect_rationale_elevation">Höhenmeter · wie viel du gestiegen bist, was eine flache Fahrt von einer hügeligen unterscheidet.</string>
<string name="health_connect_rationale_limits_title">Was Amethyst nicht tut</string>
<string name="health_connect_rationale_limit_window">Liest nur Workouts, die in den letzten 7 Tagen beendet wurden, und nur während der Workout-Editor geöffnet ist. Im Hintergrund wird nie gelesen.</string>
<string name="health_connect_rationale_limit_write">Schreibt nie etwas in Health Connect und fragt nie nach deiner Trainingsroute, deinem Standort oder anderen Gesundheitsdaten.</string>
<string name="health_connect_rationale_limit_publish">Nichts verlässt dein Telefon, bis du auf einen Vorschlag tippst und den Beitrag selbst veröffentlichst. Amethyst hat keinen Server: Der Beitrag geht an die Nostr-Relays, die du eingerichtet hast.</string>
<string name="health_connect_rationale_limit_optional">Die gesamte Funktion ist optional. Schalte sie unter Einstellungen → Editor-Einstellungen aus oder entziehe die Berechtigungen jederzeit in Health Connect — der Rest von Amethyst funktioniert weiter.</string>
<string name="health_connect_rationale_privacy_policy">Vollständige Datenschutzerklärung lesen</string>
<string name="nsite_none_found">Noch keine nSites gefunden.</string>
<string name="browser_reload">Neu laden</string>
<string name="browser_unsupported">Der In-App-Browser benötigt Android 11 oder neuer.</string>
@@ -695,6 +737,7 @@
<string name="nip46_signer_background_hint">Amethyst hält eine Hintergrundverbindung (als laufende Benachrichtigung angezeigt), um Signaturanfragen auch im geschlossenen Zustand beantworten zu können.</string>
<string name="nip46_signer_hero_title">Für andere Apps signieren</string>
<string name="nip46_signer_turn_on">Signer einschalten</string>
<string name="nip46_signer_live">Live</string>
<string name="nip46_signer_batch_remember">Diese für jede App merken</string>
<string name="nip46_signer_batch_signing_as">als %1$s</string>
<string name="nip46_signer_batch_allow">%1$d zulassen</string>
@@ -706,7 +749,9 @@
<string name="nip46_signer_act_decrypted">Eine Nachricht entschlüsselt</string>
<string name="nip46_signer_act_shared_pubkey">Deinen öffentlichen Schlüssel geteilt</string>
<string name="nip46_signer_act_connected">Verbunden</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Relays aufgelistet</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_scan_caption">Scannen, um eine App mit deinem Schlüssel zu verbinden</string>
<string name="nip46_signer_scan_connect">Einen Code scannen</string>
<string name="nip46_signer_paste_link">Stattdessen einen Link einfügen</string>
@@ -754,6 +799,7 @@
<string name="relay_auth_no_recent">Noch hat dich kein Relay zur Anmeldung aufgefordert.</string>
<string name="relay_auth_ago">vor %1$s</string>
<string name="nip82_repository_label">Quelle: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Herunterladen</string>
<string name="nip82_section_about">Über</string>
<string name="nip82_section_platforms">Plattformen</string>
@@ -791,6 +837,7 @@
<string name="nest_lobby_no_listeners">Im Raum ist gerade niemand.</string>
<string name="nest_lobby_listeners_count">%1$d hört zu</string>
<string name="nest_role_host">Gastgeber</string>
<string name="nest_role_moderator">Moderator</string>
<string name="nest_state_muted">Mikrofon stumm</string>
<string name="nest_state_speaking">Spricht gerade</string>
<string name="nest_state_mic_open">Mikrofon offen</string>
@@ -798,6 +845,8 @@
<string name="nest_leave_host_body">Du bist der Gastgeber. Wenn du den Raum schließt, werden alle getrennt. Wähle "Nur verlassen", wenn du später zurückkehren möchtest — der Raum wird nach 8 Stunden Inaktivität automatisch geschlossen.</string>
<string name="nest_leave_host_close">Raum schließen</string>
<string name="nest_leave_host_just_leave">Nur verlassen</string>
<string name="nest_live_chip">LIVE</string>
<string name="nest_tab_chat">Chat</string>
<string name="nest_tab_audience">Publikum</string>
<string name="nest_tab_hands">Hände</string>
<string name="nest_audience_empty">Noch niemand im Publikum.</string>
@@ -859,6 +908,7 @@
<string name="nests_servers_add_relay_field">Relay-URL (WebTransport)</string>
<string name="nests_servers_add_auth_field">Auth-URL (JWT-Mint)</string>
<string name="nests_servers_add_pair_button">Hinzufügen</string>
<string name="nests_servers_relay_label">Relay</string>
<string name="nests_servers_auth_label">Authentifizierung</string>
<string name="nests_servers_use_defaults">Amethyst-Standards verwenden</string>
<string name="nests_servers_add_recommended">Server hinzufügen</string>
@@ -866,13 +916,16 @@
<string name="nests_servers_empty">Noch keine Nest-Server. Füge unten einen hinzu oder wähle einen empfohlenen Server.</string>
<string name="podcast_view_episodes">Folgen ansehen</string>
<string name="podcast_no_episodes">Noch keine Folgen gefunden</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Staffel %1$d</string>
<string name="podcast_explicit">Anstößig</string>
<string name="podcast_completed">Abgeschlossen</string>
<string name="podcast_support_show">Die Show unterstützen</string>
<string name="podcast_by_author">von %1$s</string>
<string name="podcast_season_episode">S%1$d · E%2$d</string>
<string name="podcast_episode_number">Folge %1$d</string>
<string name="podcast_season">Staffel %1$d</string>
<string name="podcast_video">Video</string>
<string name="podcast_transcript">Transkript</string>
<string name="podcast_chapters">Kapitel</string>
<string name="podcast_hosts_and_guests">Hosts &amp; Gäste</string>
@@ -895,6 +948,7 @@
<string name="podcast_episode_topics_label">Themen</string>
<string name="podcast_episode_topics_placeholder">durch, Komma, getrennte, Tags</string>
<string name="podcast_episode_audio_url_label">Audio-URL</string>
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
<string name="podcast_episode_audio_picked">Audiodatei bereit zum Hochladen</string>
<string name="podcast_episode_delete">Episode löschen</string>
<string name="podcast_episode_delete_confirm">Diese Episode löschen? Das kann nicht rückgängig gemacht werden.</string>
@@ -918,6 +972,7 @@
<string name="podcast_value_search_user">Nostr-Nutzer hinzufügen</string>
<string name="podcast_value_search_user_hint">Nach Name oder @Handle suchen</string>
<string name="podcast_value_remove_recipient">Empfänger entfernen</string>
<string name="podcast_value_recipient_name">Name (optional)</string>
<string name="podcast_value_type_lnaddress">Lightning-Adresse</string>
<string name="podcast_value_type_node">Node (Keysend)</string>
<string name="podcast_value_lnaddress">Lightning-Adresse</string>
@@ -930,6 +985,7 @@
<string name="music_track_title_placeholder">Titelname</string>
<string name="music_track_artist_label">Künstler</string>
<string name="music_track_artist_placeholder">Name des Künstlers</string>
<string name="music_track_album_label">Album (optional)</string>
<string name="music_track_duration_label">Dauer in Sekunden (optional)</string>
<string name="music_track_description_label">Songtext oder Credits (optional)</string>
<string name="music_track_delete">Diesen Titel löschen</string>
@@ -945,12 +1001,14 @@
<string name="public_bookmarks">Öffentliche Lesezeichen</string>
<string name="repository_bookmarks">Repositorys</string>
<string name="repository_bookmarks_explainer">Deine gespeicherten Git-Repositorys</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_bookmarks_explainer">Deine gespeicherten Podcasts und Episoden</string>
<string name="add_to_private_bookmarks">Zu den privaten Lesezeichen hinzufügen</string>
<string name="remove_from_private_bookmarks">Aus den privaten Lesezeichen entfernen</string>
<string name="add_hashtag_label">Hashtag hinzufügen</string>
<string name="add_hashtag_label_title">Hashtag hinzufügen</string>
<string name="add_hashtag_label_explainer">Markiere diesen Beitrag öffentlich mit einem Hashtag (NIP-32-Label). Wer dir folgt, sieht ihn im Feed dieses Hashtags.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Hinzufügen</string>
<string name="hashtag_label_added_by">hinzugefügt von</string>
<string name="pinned_notes">Angeheftete Beiträge</string>
@@ -1221,6 +1279,7 @@
<string name="show_sensitive_content_explainer">Zeigt eine Warnmeldung an, wenn der Autor des Beitrags ihn als sensibel markiert hat</string>
<string name="max_hashtag_limit_title">Max. Hashtags pro Beitrag</string>
<string name="max_hashtag_limit_explainer">Blendet Beiträge mit mehr Hashtags als dieses Limit aus. Auf 0 setzen zum Deaktivieren</string>
<string name="security_unlimited"></string>
<string name="new_reaction_symbol">Neues Reaktionssymbol</string>
<string name="zapraiser">Zap-Sammlung</string>
<string name="zapraiser_explainer">Fügt einen Zielbetrag in Sats für diesen Beitrag hinzu. Unterstützende Clients können dies als Fortschrittsbalken anzeigen, um Spenden zu fördern</string>
@@ -1230,6 +1289,7 @@
<string name="owner">Inhaber</string>
<string name="used_by">Verwendet von</string>
<string name="self">Service-Schlüssel</string>
<string name="version">Version</string>
<string name="software">Programme</string>
<string name="contact">Kontakt</string>
<string name="supports">Unterstützte NIPs</string>
@@ -1251,6 +1311,7 @@
<string name="all_languages">Alle Sprachen</string>
<string name="posting_policy">Veröffentlichungsrichtlinie</string>
<string name="terms_and_conditions">Allgemeine Geschäftsbedingungen</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fehler und Hinweise von diesem Relay</string>
<string name="relay_monitor_reports">Relay-Überwachungsberichte</string>
<string name="relay_active_subscriptions">Aktive Abonnements</string>
@@ -1286,6 +1347,8 @@
<string name="cashu_redeem">Einlösen</string>
<string name="cashu_copy_token">Token kopieren</string>
<string name="quick_actions_open_in_another_app">In anderer App öffnen</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">BEENDET</string>
<string name="live_stream_planned_tag">GEPLANT</string>
<string name="live_stream_private_tag">PRIVAT</string>
@@ -1365,6 +1428,7 @@
<string name="new_conversation_best_for">Am besten für</string>
<string name="new_conversation_pros">Gut</string>
<string name="new_conversation_cons">Kompromisse</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">Nach Relay</string>
<string name="relay_group_view_inline_desc">Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats.</string>
<string name="relay_group_view_grouped_desc">Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht.</string>
@@ -1382,6 +1446,7 @@
<string name="relay_group_join_confirm">Beitreten</string>
<string name="relay_group_pending">Angefragt</string>
<string name="relay_group_role_admin">Administrator</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_role_member">Mitglied</string>
<string name="relay_group_members_title">Mitglieder</string>
<string name="relay_group_make_admin">Zum Admin machen</string>
@@ -1392,6 +1457,7 @@
<string name="relay_group_remove_user_confirm">%1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden.</string>
<string name="relay_group_menu_members">Mitglieder</string>
<string name="relay_group_menu_edit">Gruppe bearbeiten</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Nachricht anheften</string>
<string name="relay_group_unpin_message">Nachricht loslösen</string>
<string name="relay_group_pinned_label">Angeheftet</string>
@@ -1403,7 +1469,10 @@
<string name="relay_group_invite_preparing">Einladung wird vorbereitet…</string>
<string name="relay_group_badge_private">Privat</string>
<string name="relay_group_badge_invite_only">Nur mit Einladung</string>
<string name="relay_group_badge_live">LIVE</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Gib eine gültige Relay-URL ein (wss://…).</string>
<string name="relay_group_field_name">Name</string>
<string name="relay_group_field_about">Beschreibung</string>
<string name="relay_group_field_picture">Gruppenbild</string>
<string name="relay_group_add_photo">Foto hinzufügen</string>
@@ -1494,6 +1563,7 @@
<string name="poll_zap_value_min_max_explainer">Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Du kannst einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwende denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lass die Felder leer, um jeden Betrag zu akzeptieren.</string>
<string name="error_dialog_talk_to_user">Mit dem Benutzer kommunizieren</string>
<string name="error_dialog_talk_to_user_name">Nachricht %1$s</string>
<string name="error_dialog_button_ok">OK</string>
<string name="zap_split_title">Zaps aufteilen und weiterleiten</string>
<string name="zap_split_explainer">Unterstützende Clients werden Zaps an die hier hinzugefügten Benutzer aufteilen und weiterleiten, anstatt an dich</string>
<string name="zap_split_search_and_add_user">Benutzer suchen und hinzufügen</string>
@@ -1527,6 +1597,7 @@
<string name="send_the_seller_a_message">Dem Verkäufer eine Nachricht senden</string>
<string name="hi_there_is_this_still_available">Hallo, ist das noch verfügbar?</string>
<string name="classifieds_title">Titel</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Zustand</string>
<string name="classifieds_category">Kategorie</string>
<string name="classifieds_price">Preis (in Sats)</string>
@@ -1572,6 +1643,7 @@
<string name="wallet_outgoing">Gesendet</string>
<string name="wallet_refresh">Aktualisieren</string>
<string name="wallet_filter_all">Alle</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Keine Zaps</string>
<string name="wallet_add">Wallet hinzufügen</string>
<string name="wallet_default">Standard</string>
@@ -1617,10 +1689,12 @@
<string name="cashu_wizard_set_main_first">Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren.</string>
<string name="cashu_wizard_adopting">Deine Wallet wird eingerichtet…</string>
<string name="cashu_wizard_retry">Erneut suchen</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="cashu_created_title">Wallet erstellt</string>
<string name="cashu_created_subtitle">Wähle nun ein paar Mints, die deine Sats verwahren.</string>
<string name="cashu_created_pick_mints">Mints auswählen</string>
<string name="cashu_balance">Guthaben</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_mint_url">Mint-URL</string>
<string name="cashu_remove_mint">Mint entfernen</string>
<string name="cashu_add_mint">Mint hinzufügen</string>
@@ -1681,7 +1755,9 @@
<string name="cashu_getting_quote">Frage Mint nach einem Angebot…</string>
<string name="cashu_verify">Prüfen</string>
<string name="cashu_mint_reachable">✓ Mint ist erreichbar</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Mint nicht erreichbar: %1$s</string>
<string name="nutzap">Nutzap</string>
<string name="cashu_create_token">Token erstellen</string>
<string name="cashu_redeem_button">Einlösen</string>
<string name="cashu_done">Fertig</string>
@@ -1716,9 +1792,11 @@
<string name="onchain_send_recipient_placeholder">Name, NIP-05 oder npub</string>
<string name="onchain_send_change_recipient">Empfänger ändern</string>
<string name="onchain_send_amount">Betrag</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="onchain_send_min_warning">Der minimale On-Chain-Zap beträgt %1$s sats — kleinere Beträge werden von den Miner-Gebühren aufgezehrt. Nutze stattdessen einen Lightning-Zap.</string>
<string name="onchain_send_comment_label">Kommentar (optional)</string>
<string name="onchain_send_priority">Priorität</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_loading_fees">Gebührenschätzungen werden geladen…</string>
<string name="onchain_send_button_amount">%1$s sats senden</string>
<string name="onchain_send_dont_split">Nicht aufteilen — stattdessen einen Empfänger bezahlen</string>
@@ -1727,6 +1805,7 @@
<string name="onchain_send_result_transaction">Transaktion</string>
<string name="onchain_send_result_fee">Gebühr</string>
<string name="onchain_send_result_change">Wechselgeld</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_done">Fertig</string>
<string name="onchain_send_failed_at">Fehlgeschlagen bei: %1$s</string>
<string name="onchain_send_broadcast_no_receipt">Zahlung wurde gesendet (tx %1$s), aber die Quittung wurde nicht veröffentlicht.</string>
@@ -1785,6 +1864,7 @@
<string name="calendar_collection_description">Beschreibung</string>
<string name="calendar_collection_invalid">Ein Titel ist erforderlich.</string>
<string name="calendar_collection_empty_members">Noch keine Termine in diesem Kalender.</string>
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
<string name="calendar_rsvp_none">Noch keine Antworten.</string>
<string name="calendar_participants_section">Teilnehmer (%1$d)</string>
<string name="calendar_event_in_calendars">In Kalendern (%1$d)</string>
@@ -1819,6 +1899,7 @@
<string name="article_description_placeholder">Eine kurze Beschreibung deines Artikels\u2026</string>
<string name="url_slug">URL-Slug</string>
<string name="url_slug_placeholder">artikel-url-slug</string>
<string name="tags_label"># Tags</string>
<string name="add_a_tag">Tag hinzufügen\u2026</string>
<string name="start_writing_article">Beginne deinen Artikel zu schreiben\u2026</string>
<string name="reply_description">Antworten</string>
@@ -1847,9 +1928,11 @@
<string name="reactions_settings_reorder">Neu anordnen</string>
<string name="reactions_settings_reply">Antworten</string>
<string name="reactions_settings_reply_description">Auf diese Notiz antworten</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost_description">Diese Notiz reposten oder zitieren</string>
<string name="reactions_settings_like">Gefällt mir</string>
<string name="reactions_settings_like_description">Mit einem Emoji auf diese Notiz reagieren</string>
<string name="reactions_settings_zap">Zap</string>
<string name="reactions_settings_zap_description">Eine Lightning-Zahlung an den Autor senden</string>
<string name="reactions_settings_share">Teilen</string>
<string name="reactions_settings_share_description">Diese Notiz extern teilen</string>
@@ -1921,6 +2004,7 @@
<string name="buzz_system_message_deleted">%1$s hat eine Nachricht gelöscht</string>
<string name="buzz_system_message_deleted_reason">%1$s hat eine Nachricht gelöscht: %2$s</string>
<string name="buzz_system_dm_created">%1$s hat diese Unterhaltung begonnen</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_huddle_started">🔊 %1$s hat einen Huddle gestartet</string>
<string name="buzz_huddle_joined">🔊 %1$s ist dem Huddle beigetreten</string>
<string name="buzz_huddle_left">🔊 %1$s hat den Huddle verlassen</string>
@@ -1933,9 +2017,11 @@
<string name="buzz_job_error">⚠ Job-Fehler</string>
<string name="buzz_forum_upvoted">▲ %1$s hat einen Beitrag hochgestuft</string>
<string name="buzz_forum_downvoted">▼ %1$s hat einen Beitrag herabgestuft</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">In diesem Workspace wurde noch kein Canvas geteilt.</string>
<string name="buzz_canvas_edit">Canvas bearbeiten</string>
<string name="buzz_canvas_save">Canvas speichern</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_editing_banner">Nachricht bearbeiten</string>
<string name="message_edited">(bearbeitet)</string>
<string name="buzz_typing_one">%1$s tippt…</string>
@@ -1955,6 +2041,7 @@
<string name="buzz_dm_add_member_title">Jemanden zu dieser DM hinzufügen</string>
<string name="buzz_dm_add_member_invalid">Kein gültiger npub- oder Hex-Schlüssel</string>
<string name="buzz_dm_just_you">Nur du</string>
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">An</string>
<string name="buzz_dm_add_hint">Jemanden hinzufügen (npub oder hex)</string>
<string name="buzz_add_people_hint">Nach Name, NIP-05 oder npub suchen</string>
@@ -1964,6 +2051,7 @@
<string name="buzz_dm_remove">Entfernen</string>
<string name="buzz_invite_title">Workspace-Einladung</string>
<string name="buzz_invite_heading">Diesem Workspace beitreten</string>
<string name="buzz_invite_workspace">Workspace</string>
<string name="buzz_invite_role">Rolle</string>
<string name="buzz_invite_body">Du öffnest den Workspace in einem sicheren In-App-Browser, um die Bedingungen zu prüfen und den Beitritt abzuschließen. Die Anmeldung erfolgt mit deinem Amethyst-Schlüssel — ohne Passwort.</string>
<string name="buzz_invite_continue">Im Browser fortfahren</string>
@@ -1985,6 +2073,7 @@
<string name="buzz_invite_share">Teilen</string>
<string name="buzz_invite_copy">Kopieren</string>
<string name="buzz_invite_error_title">Einladung konnte nicht erstellt werden</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_dm_section_empty">Noch keine Unterhaltungen</string>
<string name="chat_delivery_details_title">Nachrichtenzustellung</string>
<string name="close">Schließen</string>
@@ -2027,10 +2116,13 @@
<string name="donate_now">Jetzt spenden</string>
<string name="brought_to_you_by">wurde Ihnen präsentiert von:</string>
<string name="this_version_brought_to_you_by">Diese Version wurde Ihnen präsentiert von:</string>
<string name="version_name">Version %1$s</string>
<string name="thank_you">Vielen Dank!</string>
<string name="restricted_writes">Eingeschränkte Schriften</string>
<string name="forked_from">Geforkt von</string>
<string name="git_clone_address">Klonen:</string>
<string name="git_branch">Branch</string>
<string name="git_commit">Commit</string>
<string name="git_merge_base">Merge-Basis</string>
<string name="git_pr_update_description">Der Pull-Request wurde auf einen neuen Commit aktualisiert.</string>
<string name="git_repo_tab_issues">Tickets</string>
@@ -2039,6 +2131,8 @@
<string name="git_repo_filter_closed">Geschlossen &amp; erledigt</string>
<string name="git_repo_label_all">Alle</string>
<string name="git_repo_bookmark">Repository als Lesezeichen speichern</string>
<string name="git_repo_stat_branches">Branches</string>
<string name="git_repo_stat_tags">Tags</string>
<string name="git_repo_stat_files">Dateien</string>
<string name="git_repo_stat_updated">Aktualisiert</string>
<string name="git_repo_recent_activity">Letzte Aktivität</string>
@@ -2047,6 +2141,7 @@
<string name="git_repo_open_in_browser">Im Browser öffnen</string>
<string name="git_untitled">Ohne Titel</string>
<string name="git_repo_personal_fork">Persönlicher Fork</string>
<string name="git_repo_tab_code">Code</string>
<string name="git_repo_code_loading">Repository wird geladen…</string>
<string name="git_repo_code_error">Das Repository konnte nicht von seiner Klon-URL geladen werden.</string>
<string name="git_repo_no_clone_url">Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen.</string>
@@ -2056,10 +2151,15 @@
<string name="git_repo_empty_folder">Dieser Ordner ist leer.</string>
<string name="git_repo_root">Root</string>
<string name="git_repo_retry">Erneut versuchen</string>
<string name="git_repo_default_branch">default</string>
<string name="git_repo_branches">Branches</string>
<string name="git_repo_tags">Tags</string>
<string name="git_repo_search_files">Dateien durchsuchen…</string>
<string name="git_repo_no_search_results">Keine passenden Dateien.</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_no_commits">Kein Commit-Verlauf verfügbar.</string>
<string name="git_repo_copy_file">Dateiinhalt kopieren</string>
<string name="git_repo_plain_text">Text</string>
<string name="git_new_issue_button">Neu</string>
<string name="git_new_issue_title">Neues Issue</string>
<string name="git_new_issue_subject">Titel</string>
@@ -2077,6 +2177,7 @@
<string name="git_pr_changes_retry">Änderungen erneut laden</string>
<string name="git_pr_revised">Überarbeitet</string>
<string name="git_repo_settings_title">Repository bearbeiten</string>
<string name="git_repo_settings_name">Name</string>
<string name="git_repo_settings_description">Beschreibung</string>
<string name="git_repo_settings_clone_urls">Klon-URLs (eine pro Zeile)</string>
<string name="git_repo_settings_web_urls">Web-URLs (eine pro Zeile)</string>
@@ -2121,6 +2222,7 @@
<string name="dvm_home_status_payment_required">Dieser Feed-Algorithmus erfordert eine Zahlung</string>
<string name="dvm_home_status_error">Der Feed-Algorithmus hat einen Fehler zurückgegeben</string>
<string name="dvm_home_retry">Wiederholen</string>
<string name="dvm_offline">Offline</string>
<string name="dvm_offline_banner">Dieser Feed-Algorithmus hat in letzter Zeit kein Lebenszeichen gesendet und ist möglicherweise nicht erreichbar</string>
<string name="temporary_account">Beim Sperren des Geräts abmelden</string>
<string name="group_relay">Chat-Relays</string>
@@ -2131,6 +2233,7 @@
<string name="search_scope_people">Personen</string>
<string name="search_scope_notes">Beiträge</string>
<string name="search_source_local">Lokal</string>
<string name="search_source_relays">Relays</string>
<string name="search_follows_only">Nur Gefolgte</string>
<string name="search_sort_newest">Neueste</string>
<string name="search_sort_oldest">Älteste</string>
@@ -2147,6 +2250,7 @@
<string name="search_filters_section_source">Quelle</string>
<string name="search_filters_section_sort">Sortieren nach</string>
<string name="search_filters_open">Filter</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="search_languages">Sprachen suchen</string>
<string name="add_language">Sprache hinzufügen</string>
<string name="add_language_pair">Sprachpaar hinzufügen</string>
@@ -2206,6 +2310,7 @@
<string name="timeout">Zeitüberschreitung</string>
<string name="retrying">Erneuter Versuch…</string>
<string name="dismiss">Schließen</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Senden</string>
<string name="broadcasting_name">Sende %1$s</string>
<string name="broadcasting_number_events">Sende %1$d Ereignisse…</string>
@@ -2280,6 +2385,7 @@
<string name="event_sync_start">Synchronisierung starten</string>
<string name="event_sync_start_anyway">Trotzdem starten (mobile Daten)</string>
<string name="event_sync_cancel">Abbrechen</string>
<string name="event_sync_relays_progress">Relays: %1$d / %2$d</string>
<string name="event_sync_events_sent">Ereignisse verteilt: %1$d neue von %2$d gesendeten und %3$d empfangenen</string>
<string name="event_sync_done_title">Synchronisierung abgeschlossen</string>
<string name="event_sync_done_sent">%1$d Ereignisse an Ziel-Relays weitergeleitet von %2$d empfangenen.</string>
@@ -2287,6 +2393,9 @@
<string name="event_sync_done_duration">Abgeschlossen in %1$d Sekunden.</string>
<string name="event_sync_error_title">Synchronisierungsfehler</string>
<string name="event_sync_sending_to">Senden an</string>
<string name="event_sync_outbox_relays">Outbox</string>
<string name="event_sync_inbox_relays">Inbox</string>
<string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_activity_log">Wird geprüft (%1$d Relays)</string>
<string name="event_sync_activity_log_finished">Abgeschlossen (%1$d Relays)</string>
<string name="event_sync_log_sent">ges. %1$s</string>
@@ -2306,6 +2415,7 @@
<string name="cashu_move_coins_withdraw_ln">Über Lightning abheben…</string>
<string name="cashu_move_coins_export_token">Als Cashu-Token exportieren…</string>
<string name="cashu_move_coins_working">Coins werden verschoben…</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Verbinden</string>
<string name="event_sync_status_downloading">Herunterladen</string>
<string name="event_sync_status_error">Fehler</string>
@@ -2315,6 +2425,7 @@
<string name="profile_actions_dialog_title">Profilaktionen</string>
<string name="media_actions_dialog_title">Medienaktionen</string>
<string name="playback_actions_dialog_title">Wiedergabe</string>
<string name="video_quality_auto">Auto</string>
<string name="edit_nickname">Spitznamen bearbeiten</string>
<string name="nickname_private">Nur für dich sichtbar</string>
<string name="cast_to_device_dialog_title">Streamen an…</string>
@@ -2327,7 +2438,9 @@
<string name="hls_description_label">Beschreibung</string>
<string name="hls_description_placeholder">Worum geht es in diesem Video?</string>
<string name="hls_content_warning_reason_placeholder">Grund (optional)</string>
<string name="hls_codec_label">Codec</string>
<string name="hls_codec_h265">H.265 (bessere Komprimierung)</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_fallback_notice">H.265 ist auf diesem Gerät nicht verfügbar — wechsle zu H.264.</string>
<string name="hls_renditions_label">Versionen</string>
<string name="hls_renditions_source_format">Quellauflösung: %1$d×%2$d</string>
@@ -2365,12 +2478,14 @@
<string name="event_sync_date_filter_until">Bis</string>
<string name="event_sync_date_filter_now">Jetzt</string>
<string name="event_sync_date_filter_all_time">Gesamter Zeitraum</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://beispiel.com</string>
<string name="web_bookmark_title_label">Titel</string>
<string name="web_bookmark_title_placeholder">Lesezeichen-Titel</string>
<string name="web_bookmark_description_label">Beschreibung</string>
<string name="web_bookmark_description_placeholder">Eine kurze Beschreibung</string>
<string name="web_bookmark_tags_label">Tags (kommagetrennt)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Speichern</string>
<string name="web_bookmark_delete">Löschen</string>
<string name="web_bookmark_delete_confirm">Dieses Web-Lesezeichen löschen?</string>
@@ -2379,11 +2494,13 @@
<string name="fundraiser_ends">Endet %1$s</string>
<string name="fundraiser_onchain_donation">On-Chain-Spende</string>
<string name="goal_amount_label">Zielbetrag (Sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beschreibe dein Ziel</string>
<string name="goal_description_placeholder">Wofür sammelst du Geld?</string>
<string name="goal_summary_label">Kurze Zusammenfassung</string>
<string name="goal_summary_placeholder">Kurze Beschreibung für Vorschauen</string>
<string name="goal_image_label">Bild-URL (optional)</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="goal_website_label">Website-URL (optional)</string>
<string name="goal_website_placeholder">https://beispiel.com</string>
<string name="goal_set_deadline">Frist festlegen</string>
@@ -2490,6 +2607,7 @@
<string name="ai_tone_elaborate">Ausführen</string>
<string name="ai_tone_friendly">Freundlich</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="emoji_packs_title">Emoji-Pakete</string>
<string name="emoji_pack_management_title">Zur Emoji-Liste hinzufügen</string>
<string name="add_to_emoji_list">Zu meiner Emoji-Liste hinzufügen</string>
@@ -2518,6 +2636,7 @@
<string name="emoji_pack_actions_dialog_title">Aktionen für Emoji-Paket</string>
<string name="emoji_private_toggle">Privat</string>
<string name="emoji_private_badge">Privates Emoji</string>
<string name="gif">Gif</string>
<string name="community_rules_violation_icon">Verstoß gegen Community-Regeln</string>
<string name="community_rules_violation_author_denied">Du stehst auf der Sperrliste dieser Community. Beiträge werden nicht akzeptiert.</string>
<string name="community_rules_violation_kind_not_allowed">Diese Community akzeptiert keine Ereignisse vom Typ %1$d.</string>
@@ -2538,6 +2657,7 @@
<string name="marmot_disband_group_confirm">„%1$s“ für alle auflösen? Die Unterhaltung endet für jedes Mitglied und kann nicht wieder geöffnet werden — es müsste eine neue Gruppe erstellt werden.</string>
<string name="marmot_disband_group_action">Auflösen</string>
<string name="marmot_avatar_url">Avatar-Link</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">Ein https-Link, den jedes Mitglied laden kann. Leer lassen, um stattdessen das hochgeladene Bild zu verwenden.</string>
<string name="marmot_legacy_group_no_avatar_url">Diese Gruppe wurde erstellt, bevor Link-Avatare unterstützt wurden, und kann daher nur ein hochgeladenes Bild verwenden. Bestehende Gruppen können nicht aktualisiert werden — erstelle eine neue Gruppe, um einen Link zu verwenden.</string>
<string name="marmot_enable_encrypted_media">Verschlüsselte Anhänge verwenden</string>
@@ -2545,6 +2665,7 @@
<string name="marmot_legacy_group_no_disband">Diese Gruppe wurde erstellt, bevor das Auflösen unterstützt wurde, und kann nicht aufgelöst werden. Du kannst sie trotzdem verlassen oder alle anderen Mitglieder entfernen. Bestehende Gruppen können nicht aktualisiert werden.</string>
<string name="marmot_group_default_name">Marmot-Gruppe</string>
<string name="marmot_group_fallback_name">Gruppe %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="marmot_tab_known">Bekannt</string>
<string name="marmot_tab_known_count">Bekannt (%1$d)</string>
<string name="marmot_tab_new_requests">Neue Anfragen</string>
@@ -2604,6 +2725,7 @@
<string name="marmot_revoke_admin_privileges">Administratorrechte widerrufen</string>
<string name="marmot_revoke_admin_title">Administratorrechte widerrufen</string>
<string name="marmot_revoke_admin_confirm">Administratorrechte von "%1$s" widerrufen? Diese Person bleibt Mitglied der Gruppe.</string>
<string name="marmot_relays_header">Relays</string>
<string name="marmot_relay_no_events">noch keine Ereignisse</string>
<string name="marmot_relay_last_event">letztes Ereignis%1$s</string>
<string name="copy">Kopieren</string>
@@ -2612,6 +2734,7 @@
<string name="dvm_pay_invoice_from_dvm">Rechnung vom DVM bezahlen</string>
<string name="dvm_pay_amount_to_dvm">%1$s sats an das DVM zahlen</string>
<string name="geohash_chat_open">Standort-Chat öffnen</string>
<string name="buzz_job_board_title">Backlog</string>
<string name="buzz_workflow_runs_title">Workflow-Läufe</string>
<string name="buzz_agent_work_title">Agentenarbeit</string>
<string name="buzz_workflow_new_run">Neuer Lauf</string>
@@ -2622,6 +2745,7 @@
<string name="buzz_workflow_gate_needs_you">Benötigt deine Genehmigung</string>
<string name="buzz_workflow_gate_awaiting">Wartet auf Genehmigung</string>
<string name="buzz_workflow_no_description">(keine Beschreibung)</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_by">von</string>
<string name="buzz_workflow_waiting_on">wartet auf</string>
<string name="buzz_workflow_readonly_approver">Du bist der Genehmigende, aber diese Anmeldung kann keine Entscheidung signieren.</string>
@@ -2649,11 +2773,13 @@
<string name="buzz_workflow_no_defs_hint">Noch keine Workflows. Öffne das Menü oben und wähle „Neue Definition…“, um einen zu erstellen, und löse ihn dann aus.</string>
<string name="buzz_workflow_task_label">Was soll er tun?</string>
<string name="buzz_workflow_trigger_run">Lauf auslösen</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="buzz_workflow_picker_empty">Noch keine Workflows definiert</string>
<string name="buzz_workflow_picker_choose">Workflow auswählen</string>
<string name="buzz_workflow_new_definition">Neue Definition…</string>
<string name="buzz_workflow_def_title">Neue Workflow-Definition</string>
<string name="buzz_workflow_def_desc">Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur.</string>
<string name="buzz_workflow_def_name">Name</string>
<string name="buzz_workflow_def_name_hint">build-und-test</string>
<string name="buzz_workflow_def_yaml">YAML-Rezept</string>
<string name="buzz_workflow_def_publish_failed">Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst.</string>
@@ -2697,131 +2823,5 @@
<string name="buzz_persona_publishing">Wird veröffentlicht…</string>
<string name="buzz_persona_publish">Persona veröffentlichen</string>
<string name="profile_card_follows_you">Folgt dir</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="app_definition_kind_app">App</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="badge_name_label">Name</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_invite_workspace">Workspace</string>
<string name="buzz_job_board_title">Backlog</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_workflow_def_name">Name</string>
<string name="buzz_workflow_id_prefix">Workflow: %1$s</string>
<string name="buzz_workflow_picker_label">Workflow</string>
<string name="calendar_rsvp_section">RSVPs (%1$d)</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="chat_minichat_title">Thread</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="clink_budget_set">Budget</string>
<string name="concord_create_name">Name</string>
<string name="concord_view_inline">Inline</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="dvm_offline">Offline</string>
<string name="error_dialog_button_ok">OK</string>
<string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_inbox_relays">Inbox</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_outbox_relays">Outbox</string>
<string name="event_sync_relays_progress">Relays: %1$d / %2$d</string>
<string name="feed">Feed</string>
<string name="fork">Fork</string>
<string name="gif">Gif</string>
<string name="git_branch">Branch</string>
<string name="git_commit">Commit</string>
<string name="git_repo_branches">Branches</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_default_branch">default</string>
<string name="git_repo_plain_text">Text</string>
<string name="git_repo_settings_name">Name</string>
<string name="git_repo_stat_branches">Branches</string>
<string name="git_repo_stat_tags">Tags</string>
<string name="git_repo_tab_code">Code</string>
<string name="git_repo_tags">Tags</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_label">Codec</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="malware">Malware</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_relays_header">Relays</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="music_track_album_label">Album (optional)</string>
<string name="my_awesome_name">Ostrich McAwesome</string>
<string name="nest_live_chip">LIVE</string>
<string name="nest_role_moderator">Moderator</string>
<string name="nest_tab_chat">Chat</string>
<string name="nests_servers_relay_label">Relay</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_live">Live</string>
<string name="nip82_version_label">v%1$s</string>
<string name="not_available_acronym">N/A</string>
<string name="nutzap">Nutzap</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="platform_web">Web</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_episode_audio_url_placeholder">https://…/episode.mp3</string>
<string name="podcast_season_episode">S%1$d · E%2$d</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_value_recipient_name">Name (optional)</string>
<string name="podcast_video">Video</string>
<string name="post_not_found_short">👀</string>
<string name="profile_apps_header">Apps · %1$d</string>
<string name="profile_apps_header_empty">Apps</string>
<string name="profile_card_bot">Bot</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_zap">Zap</string>
<string name="relay_group_badge_live">LIVE</string>
<string name="relay_group_field_name">Name</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relays"> Relays</string>
<string name="search_source_relays">Relays</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="security_unlimited"></string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="share_of">%1$d/%2$d</string>
<string name="tags_label"># Tags</string>
<string name="version">Version</string>
<string name="version_name">Version %1$s</string>
<string name="video_quality_auto">Auto</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_url_label">URL</string>
<string name="website">Website</string>
<string name="workout">Workout</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="health_connect_rationale_headline">Amethyst liest abgeschlossene Workouts, um einen Workout-Beitrag für dich vorauszufüllen.</string>
<string name="health_connect_rationale_title">Health Connect und Amethyst</string>
<string name="health_connect_rationale_intro">Amethyst ist ein sozialer Nostr-Client. Im Bereich Workouts kannst du eine Zusammenfassung eines abgeschlossenen Workouts an die Nostr-Relays deiner Wahl veröffentlichen, damit die Leute, die dir folgen, sehen können, was du gemacht hast. Statt jede Zahl von Hand einzutippen, kann Amethyst das Workout lesen, das deine Uhr oder Fitness-App bereits in Health Connect gespeichert hat, und den Beitrag vorausfüllen. Du siehst den vorausgefüllten Beitrag immer und entscheidest, ob du ihn veröffentlichst.</string>
<string name="health_connect_rationale_what_title">Was Amethyst liest und warum</string>
<string name="health_connect_rationale_exercise">Übung · welche Aktivität du gemacht hast, wann sie begann und wie lange sie dauerte — das ist das Workout selbst sowie Titel und Dauer des Beitrags.</string>
<string name="health_connect_rationale_steps">Schritte · die Schrittzahl eines Laufs, Spaziergangs oder einer Wanderung.</string>
<string name="health_connect_rationale_distance">Distanz · wie weit du gekommen bist, angezeigt als Distanz des Laufs, der Fahrt, des Spaziergangs oder des Schwimmens.</string>
<string name="health_connect_rationale_calories">Aktive und gesamte Kalorien · die beim Workout verbrannte Energie. Aktive Kalorien werden verwendet, wenn deine Quelle sie aufzeichnet; gesamte Kalorien sind der Ersatz für Quellen, die nur die Gesamtenergie aufzeichnen.</string>
<string name="health_connect_rationale_elevation">Höhenmeter · wie viel du gestiegen bist, was eine flache Fahrt von einer hügeligen unterscheidet.</string>
<string name="health_connect_rationale_heart_rate">Herzfrequenz · die durchschnittliche und maximale Herzfrequenz während des Workouts, das übliche Maß für die Anstrengung.</string>
<string name="health_connect_rationale_limits_title">Was Amethyst nicht tut</string>
<string name="health_connect_rationale_limit_window">Liest nur Workouts, die in den letzten 7 Tagen beendet wurden, und nur während der Workout-Editor geöffnet ist. Im Hintergrund wird nie gelesen.</string>
<string name="health_connect_rationale_limit_publish">Nichts verlässt dein Telefon, bis du auf einen Vorschlag tippst und den Beitrag selbst veröffentlichst. Amethyst hat keinen Server: Der Beitrag geht an die Nostr-Relays, die du eingerichtet hast.</string>
<string name="health_connect_rationale_limit_write">Schreibt nie etwas in Health Connect und fragt nie nach deiner Trainingsroute, deinem Standort oder anderen Gesundheitsdaten.</string>
<string name="health_connect_rationale_limit_optional">Die gesamte Funktion ist optional. Schalte sie unter Einstellungen → Editor-Einstellungen aus oder entziehe die Berechtigungen jederzeit in Health Connect — der Rest von Amethyst funktioniert weiter.</string>
<string name="health_connect_rationale_privacy_policy">Vollständige Datenschutzerklärung lesen</string>
<string name="workout_suggestion_connect_details">Was Amethyst liest</string>
</resources>
@@ -246,6 +246,7 @@
<string name="lightning_invoice">Lightning-számla</string>
<string name="invoice_expired">Lejárt</string>
<string name="clink_lightning_offer">CLINK ajánlat</string>
<string name="bolt12_lightning_offer">BOLT12-ajánlat</string>
<string name="clink_offer_pay_to">Neki:</string>
<string name="clink_confirm_payment_title">Kifizetés megerősítése</string>
<string name="clink_offer_amount_sats">Összeg (satoshiban)</string>
@@ -649,6 +650,23 @@
<string name="workout_suggestion_connect_button">Kapcsolódás</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Health Connectből</string>
<string name="workout_suggestion_connect_details">Mihez fér hozzá az Amethyst</string>
<string name="health_connect_rationale_title">Health Connect és Amethyst</string>
<string name="health_connect_rationale_headline">Az Amethyst beolvassa a befejezett edzéseket, így előre kitöltheti Önnek az edzésről szóló bejegyzéseit.</string>
<string name="health_connect_rationale_intro">Az Amethyst egy kliens a Nostr közösségi hálózathoz. Az „Edzések” menüpontjában közzéteheti egy befejezett edzésének az összefoglalóját a kiválasztott Nostr-átjátszókon, így a követői láthatják, hogy Ön mit csinál. Ahelyett, hogy minden adatot kézzel beírna, az Amethyst beolvashatja az órája vagy a fitneszalkalmazása által a Health Connectbe már elmentett edzésadatokat, és előre kitöltheti a bejegyzést. Ön mindig a már kitöltött teljes bejegyzést látja, és dönthet arról, hogy közzéteszi-e.</string>
<string name="health_connect_rationale_what_title">Mihez fér hozzá az Amethyst, és miért</string>
<string name="health_connect_rationale_exercise">Edzés · milyen tevékenységet végzett, mikor kezdődött és meddig tartott; ez maga az edzés, valamint a bejegyzés címe és időtartama.</string>
<string name="health_connect_rationale_distance">Távolság · a megtett út hossza (futás, kerékpározás, gyaloglás vagy úszáskor).</string>
<string name="health_connect_rationale_calories">Aktív és összes kalória · az edzés közben elégetett energia. Az aktív kalóriaértékek akkor használandók, ha az adatforrás rögzíti őket; az összes kalóriaérték pedig tartalék megoldásként szolgál azon források esetében, amelyek csak a teljes energiafelhasználást rögzítik.</string>
<string name="health_connect_rationale_heart_rate">Pulzus · az edzés alatti átlagos és maximális pulzus; az erőkifejtés intenzitásának szokásos mérőszáma.</string>
<string name="health_connect_rationale_steps">Lépések · futás, séta vagy túra során megtett lépések száma.</string>
<string name="health_connect_rationale_elevation">Szintemelkedés · mekkora szintkülönbséget küzdött le; ez különbözteti meg a sík terepen zajló tekerést a dombos útvonaltól.</string>
<string name="health_connect_rationale_limits_title">Mit nem csinál az Amethyst</string>
<string name="health_connect_rationale_limit_window">Csak az elmúlt 7 napban befejezett edzéseket olvassa be, és kizárólag akkor, ha az edzésszerkesztő meg van nyitva. Soha nem olvas be adatokat a háttérben.</string>
<string name="health_connect_rationale_limit_write">Soha nem ír semmit a Health Connectbe, és soha nem kéri le az edzési útvonalat, a tartózkodási helyet vagy bármilyen egyéb egészségügyi adatot.</string>
<string name="health_connect_rationale_limit_publish">Semmi sem hagyja el az Ön eszközét, amíg rá nem koppint egy javaslatra, és saját maga közzé nem teszi a bejegyzést. Az Amethyst nem használ kiszolgálót: a bejegyzés az Ön által beállított Nostr-átjátszókra kerül.</string>
<string name="health_connect_rationale_limit_optional">Egyetlen funkció sem kötelező. Bármikor kikapcsolhatja a Beállítások → Írás menüpontban, vagy visszavonhatja a Health Connectben megadott engedélyeket; az Amethyst többi része továbbra is működni fog.</string>
<string name="health_connect_rationale_privacy_policy">Olvassa el a teljes adatvédelmi nyilatkozatot</string>
<string name="nsite_none_found">Még nem találhatók nOldalak.</string>
<string name="browser_reload">Újratöltés</string>
<string name="browser_unsupported">Az alkalmazáson belüli böngészőhöz Android 11 vagy újabb verzió szükséges.</string>
@@ -195,6 +195,7 @@
<string name="post_was_hidden">Esta postagem foi ocultada porque menciona seus usuários ou palavras ocultas</string>
<string name="post_was_flagged_as_inappropriate_by">A postagem foi sinalizada como imprópria por</string>
<string name="post_not_found">postagem não encontrada</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Imagem do Canal</string>
<string name="referenced_event_not_found">Evento referenciado não encontrado</string>
<string name="could_not_decrypt_the_message">Não foi possível descriptografar a mensagem</string>
@@ -224,11 +225,13 @@
<string name="report_impersonation">Denunciar representação</string>
<string name="report_illegal_behaviour">Denunciar comportamento ilegal</string>
<string name="report_malware">Reportar Malware</string>
<string name="malware">Malware</string>
<string name="chat_zap_amount_suffix">deu zap de %1$s sats</string>
<string name="chat_zap_anonymous">Anônimo</string>
<string name="chat_raid_is_raiding">está fazendo raid</string>
<string name="chat_clip_created_a_clip">criou um clipe</string>
<string name="edited">editado</string>
<string name="original">original</string>
<string name="quote">Citar</string>
<string name="fork">Garfo</string>
<string name="new_amount_in_sats">Novo Valor em Sats</string>
@@ -264,7 +267,10 @@
<string name="send_payment_source_external">Outro app de carteira</string>
<string name="send_payment_source_onchain_wallet">Carteira on-chain</string>
<string name="send_payment_source_cashu_wallet">Carteira Cashu</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_clink">Oferta CLINK</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_amount">Valor</string>
<string name="send_payment_fixed_price">Preço definido pela oferta</string>
<string name="send_payment_receipt_section">Recibo de zap</string>
@@ -297,6 +303,7 @@
<string name="secret_note_to_receiver">Nota Secreta para o Receptor</string>
<string name="secret_note_to_receiver_placeholder">Minha mensagem oculta</string>
<string name="secret_visible_text">Prefixo Visível</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Adicionar à postagem</string>
<string name="article">Artigo</string>
<string name="channel_name">Nome do Canal</string>
@@ -334,6 +341,7 @@
<string name="user_suggestion_in_this_chat">Neste chat</string>
<string name="edits_the_channel_metadata">Editar os dados do canal</string>
<string name="join">Entrar</string>
<string name="feed">Feed</string>
<string name="mod_queue">Fila de moderação</string>
<string name="notes">Notas</string>
<string name="replies">Respostas</string>
@@ -425,6 +433,7 @@
<string name="dm_sender_reported_hide">Ocultar</string>
<string name="dm_sender_reported_subtitle">Alguém que você segue sinalizou esta conta</string>
<string name="dm_sender_reported_icon">Denunciado</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="error_loading_replies">Erro ao carregar respostas</string>
<string name="try_again">Tente novamente</string>
<string name="notification_feed_is_empty">Ainda não há notificações.</string>
@@ -585,6 +594,7 @@
<string name="profile_badges_header">Selos · %1$d</string>
<string name="profile_badges_description">Escolha quais dos selos que você recebeu aparecerão no seu perfil.</string>
<string name="profile_badges_empty">Você ainda não recebeu nenhum selo.</string>
<string name="profile_apps_header">Apps · %1$d</string>
<string name="profile_apps_header_empty">Aplicativos</string>
<string name="profile_apps_empty_hint">Recomende os apps Nostr que você usa para que outras pessoas possam descobri-los.</string>
<string name="profile_app_recommendations_title">Apps recomendados</string>
@@ -605,7 +615,11 @@
<string name="app_definition_implements">Implementa</string>
<string name="app_definition_supported_nips">NIPs suportados</string>
<string name="app_definition_via">pelo %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_kind_app">Aplicativo</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="workout">Treino</string>
<string name="workout_title">Título</string>
<string name="workout_duration">Duração</string>
@@ -622,6 +636,7 @@
<string name="workout_reps">Repetições</string>
<string name="workout_weight">Peso</string>
<string name="workout_exercises">Exercícios</string>
<string name="workout_volume">Volume</string>
<string name="workout_notes">Notas</string>
<string name="workout_hours">Horas</string>
<string name="workout_minutes">Minutos</string>
@@ -629,7 +644,25 @@
<string name="workout_suggestion_connect_title">Compartilhe seus treinos</string>
<string name="workout_suggestion_connect_message">Permita que o Amethyst leia treinos concluídos do Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) e sugira uma publicação.</string>
<string name="workout_suggestion_connect_button">Conectar</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Do Health Connect</string>
<string name="workout_suggestion_connect_details">O que o Amethyst lê</string>
<string name="health_connect_rationale_title">Health Connect e Amethyst</string>
<string name="health_connect_rationale_headline">O Amethyst lê treinos concluídos para preencher previamente uma publicação de treino para você.</string>
<string name="health_connect_rationale_intro">O Amethyst é um cliente social do Nostr. A seção Treinos permite publicar um resumo de um treino concluído nos relays do Nostr que você escolher, para que quem segue você veja o que você fez. Em vez de digitar cada número à mão, o Amethyst pode ler o treino que seu relógio ou aplicativo de fitness já salvou no Health Connect e preencher a publicação previamente. Você sempre vê a publicação preenchida e decide se quer publicá-la.</string>
<string name="health_connect_rationale_what_title">O que o Amethyst lê e por quê</string>
<string name="health_connect_rationale_exercise">Exercício · qual atividade você fez, quando começou e quanto tempo durou — é o treino em si, além do título e da duração da publicação.</string>
<string name="health_connect_rationale_distance">Distância · o quanto você percorreu, exibido como a distância da corrida, pedalada, caminhada ou natação.</string>
<string name="health_connect_rationale_calories">Calorias ativas e totais · a energia gasta no treino. As calorias ativas são usadas quando sua fonte as registra; as calorias totais são a alternativa para fontes que registram apenas a energia total.</string>
<string name="health_connect_rationale_heart_rate">Frequência cardíaca · a frequência média e máxima durante o treino, a medida padrão do esforço.</string>
<string name="health_connect_rationale_steps">Passos · a contagem de passos de uma corrida, caminhada ou trilha.</string>
<string name="health_connect_rationale_elevation">Elevação · o quanto você subiu, que é o que distingue um percurso plano de um acidentado.</string>
<string name="health_connect_rationale_limits_title">O que o Amethyst não faz</string>
<string name="health_connect_rationale_limit_window">Lê apenas treinos concluídos nos últimos 7 dias e somente enquanto o editor de Treinos está aberto. Nunca lê em segundo plano.</string>
<string name="health_connect_rationale_limit_write">Nunca grava nada no Health Connect e nunca solicita seu trajeto de exercício, localização ou qualquer outro tipo de dado de saúde.</string>
<string name="health_connect_rationale_limit_publish">Nada sai do seu telefone até você tocar em uma sugestão e publicar por conta própria. O Amethyst não tem servidor: a publicação vai para os relays do Nostr que você configurou.</string>
<string name="health_connect_rationale_limit_optional">Todo o recurso é opcional. Desative-o em Configurações → Configurações de composição, ou revogue as permissões no Health Connect a qualquer momento — o restante do Amethyst continua funcionando.</string>
<string name="health_connect_rationale_privacy_policy">Ler a política de privacidade completa</string>
<string name="nsite_none_found">Nenhum nSite encontrado ainda.</string>
<string name="browser_reload">Recarregar</string>
<string name="browser_unsupported">O navegador integrado requer Android 11 ou superior.</string>
@@ -716,7 +749,9 @@
<string name="nip46_signer_act_decrypted">Descriptografou uma mensagem</string>
<string name="nip46_signer_act_shared_pubkey">Compartilhou sua chave pública</string>
<string name="nip46_signer_act_connected">Conectado</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Listou os relays</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_scan_caption">Escaneie para conectar um aplicativo à sua chave</string>
<string name="nip46_signer_scan_connect">Escanear um código</string>
<string name="nip46_signer_paste_link">Colar um link em vez disso</string>
@@ -764,10 +799,12 @@
<string name="relay_auth_no_recent">Nenhum relay pediu que você entrasse ainda.</string>
<string name="relay_auth_ago">há %1$s</string>
<string name="nip82_repository_label">Fonte: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Baixar</string>
<string name="nip82_section_about">Sobre</string>
<string name="nip82_section_platforms">Plataformas</string>
<string name="nip82_section_topics">Tópicos</string>
<string name="nip82_section_links">Links</string>
<string name="nip82_section_latest_release">Versão mais recente</string>
<string name="nip82_no_comments">Seja o primeiro a comentar.</string>
<string name="nip82_by_author">por</string>
@@ -879,6 +916,7 @@
<string name="nests_servers_empty">Ainda não há servidores. Adicione um abaixo ou escolha um recomendado.</string>
<string name="podcast_view_episodes">Ver episódios</string>
<string name="podcast_no_episodes">Nenhum episódio encontrado ainda</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Temporada %1$d</string>
<string name="podcast_explicit">Explícito</string>
<string name="podcast_completed">Concluído</string>
@@ -894,8 +932,10 @@
<string name="podcast_top_supporters">Principais apoiadores</string>
<string name="podcast_role_host">Apresentador</string>
<string name="podcast_role_cohost">Coapresentador</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_author_verified">Autor verificado</string>
<string name="podcast_value_stream">Transmitir sats</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_value_stream_hint">Envia valor automaticamente enquanto você ouve.</string>
<string name="podcast_value_streamed_total">%1$d sats transmitidos nesta sessão</string>
<string name="podcast_episode_title_label">Título</string>
@@ -961,12 +1001,14 @@
<string name="public_bookmarks">Itens Salvos Públicos</string>
<string name="repository_bookmarks">Repositórios</string>
<string name="repository_bookmarks_explainer">Seus repositórios git marcados</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_bookmarks_explainer">Seus podcasts e episódios marcados</string>
<string name="add_to_private_bookmarks">Adicionar aos Itens Salvos Privados</string>
<string name="remove_from_private_bookmarks">Remover dos Itens Salvos Privados</string>
<string name="add_hashtag_label">Adicionar hashtag</string>
<string name="add_hashtag_label_title">Adicionar uma hashtag</string>
<string name="add_hashtag_label_explainer">Marque publicamente este post com uma hashtag (etiqueta NIP-32). Quem segue você o verá no feed dessa hashtag.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Adicionar</string>
<string name="hashtag_label_added_by">adicionado por</string>
<string name="pinned_notes">Notas Fixadas</string>
@@ -987,6 +1029,7 @@
<string name="interest_set_name_placeholder">Meus interesses</string>
<string name="interest_set_hashtag_add_placeholder">Adicionar hashtag</string>
<string name="interest_set_hashtag_private_toggle">Privado</string>
<string name="interest_set_hashtag_count">%1$d hashtag(s)</string>
<string name="interest_set_actions_dialog_title">Ações do conjunto de interesses</string>
<string name="interest_set_clone">Clonar</string>
<string name="interest_set_add_hashtag">Adicionar hashtag</string>
@@ -1024,6 +1067,7 @@
<string name="quick_zap_amounts_explainer">Exibido ao pressionar o botão de zap. Toque em um valor para removê-lo. Se deixar vazio, abrirá o diálogo para inserir um valor todas as vezes.</string>
<string name="send_onchain_instead">Enviar on-chain em vez disso</string>
<string name="reload_mint_title">Recarregar mint</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="reload_mint_topup_label">Quantia da recarga</string>
<string name="reload_mint_section_to">Mint a recarregar</string>
<string name="reload_mint_section_from">Fundos de</string>
@@ -1235,6 +1279,7 @@
<string name="show_sensitive_content_explainer">Mostra uma mensagem de aviso quando o autor da publicação a marcar como sensível</string>
<string name="max_hashtag_limit_title">Máximo de hashtags por postagem</string>
<string name="max_hashtag_limit_explainer">Oculta postagens com mais hashtags do que este limite. Defina como 0 para desativar</string>
<string name="security_unlimited"></string>
<string name="new_reaction_symbol">Novo símbolo de reação</string>
<string name="zapraiser">Arrecadação de Zaps</string>
<string name="zapraiser_explainer">Adiciona uma quantidade alvo de sats para financiar esta postagem. Os clientes podem mostrar isso como uma barra de progresso para incentivar doações</string>
@@ -1266,6 +1311,7 @@
<string name="all_languages">Todos os idiomas</string>
<string name="posting_policy">Política de postagem</string>
<string name="terms_and_conditions">Termos &amp; condições</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Erros e Avisos deste Relé</string>
<string name="relay_monitor_reports">Relatórios do Monitor de Relay</string>
<string name="relay_active_subscriptions">Assinaturas Ativas</string>
@@ -1284,6 +1330,7 @@
<string name="accepts_up_to">Aceita até</string>
<string name="time_in_the_future">%1$s no futuro</string>
<string name="time_in_the_past">há %1$s</string>
<string name="amount_in_bits">%1$s bits</string>
<string name="event_retention">Retenção de eventos</string>
<string name="content_size">Tamanho do conteúdo</string>
<string name="connectivity">Conectividade</string>
@@ -1296,6 +1343,7 @@
<string name="max_filters_per_sub">Máx. filtros por inscrição</string>
<string name="max_limit_events_returning">Limite máx. (eventos retornados)</string>
<string name="max_subid_length">Comprimento máx. do SubID</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_redeem">Resgatar</string>
<string name="cashu_copy_token">Copiar Token</string>
<string name="quick_actions_open_in_another_app">Abrir em outro aplicativo</string>
@@ -1328,6 +1376,7 @@
<string name="accent_color">Cor de destaque</string>
<string name="accent_color_description">Cor principal usada em botões e links</string>
<string name="action_unmute">Reativar</string>
<string name="picture_in_picture">Picture-in-Picture</string>
<string name="search_button">Pesquise registros locais e remotos</string>
<string name="nip05_verified">O endereço Nostr foi verificado</string>
<string name="nip05_failed">Endereço Nostr falhou na verificação</string>
@@ -1421,6 +1470,7 @@
<string name="relay_group_badge_private">Privado</string>
<string name="relay_group_badge_invite_only">Somente por convite</string>
<string name="relay_group_badge_live">AO VIVO</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Insira uma URL de relay válida (wss://…).</string>
<string name="relay_group_field_name">Nome</string>
<string name="relay_group_field_about">Descrição</string>
@@ -1513,6 +1563,7 @@
<string name="poll_zap_value_min_max_explainer">Os votos são ponderados pelo valor do zap. Você pode definir um valor mínimo para evitar spammers e um valor máximo para evitar que grandes zappers assumam o controle da enquete. Use o mesmo valor em ambos os campos para garantir que cada voto tenha o mesmo valor. Deixe em branco para aceitar qualquer valor.</string>
<string name="error_dialog_talk_to_user">Mensagem ao usuário</string>
<string name="error_dialog_talk_to_user_name">Mensagem %1$s</string>
<string name="error_dialog_button_ok">OK</string>
<string name="zap_split_title">Dividir e encaminhar Zaps</string>
<string name="zap_split_explainer">Os clientes que suportam dividirão e encaminharão zaps para os usuários adicionados aqui em vez dos seus</string>
<string name="zap_split_search_and_add_user">Pesquisar e adicionar usuário</string>
@@ -1546,6 +1597,7 @@
<string name="send_the_seller_a_message">Envie uma mensagem para o vendedor</string>
<string name="hi_there_is_this_still_available">Olá, ainda está disponível?</string>
<string name="classifieds_title">Título</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Condição</string>
<string name="classifieds_category">Categoria</string>
<string name="classifieds_price">Preço (em Sats)</string>
@@ -1574,6 +1626,7 @@
<string name="wallet_send">Enviar</string>
<string name="wallet_receive">Receber</string>
<string name="wallet_transactions">Transações</string>
<string name="wallet_sats">sats</string>
<string name="wallet_paste_invoice">Cole uma fatura BOLT-11</string>
<string name="wallet_pay">Pagar</string>
<string name="wallet_payment_success">Pagamento bem-sucedido</string>
@@ -1590,6 +1643,7 @@
<string name="wallet_outgoing">Enviado</string>
<string name="wallet_refresh">Atualizar</string>
<string name="wallet_filter_all">Todos</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Outros</string>
<string name="wallet_add">Adicionar carteira</string>
<string name="wallet_default">Padrão</string>
@@ -1635,10 +1689,12 @@
<string name="cashu_wizard_set_main_first">Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas.</string>
<string name="cashu_wizard_adopting">Configurando sua carteira…</string>
<string name="cashu_wizard_retry">Buscar novamente</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="cashu_created_title">Carteira criada</string>
<string name="cashu_created_subtitle">Agora escolha alguns mints para hospedar seus sats.</string>
<string name="cashu_created_pick_mints">Escolher mints</string>
<string name="cashu_balance">Saldo</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_mint_url">URL do mint</string>
<string name="cashu_remove_mint">Remover mint</string>
<string name="cashu_add_mint">Adicionar mint</string>
@@ -1699,7 +1755,9 @@
<string name="cashu_getting_quote">Pedindo cotação ao mint…</string>
<string name="cashu_verify">Verificar</string>
<string name="cashu_mint_reachable">✓ O mint está acessível</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Não foi possível acessar o mint: %1$s</string>
<string name="nutzap">Nutzap</string>
<string name="cashu_create_token">Criar token</string>
<string name="cashu_redeem_button">Resgatar</string>
<string name="cashu_done">Concluído</string>
@@ -1734,9 +1792,11 @@
<string name="onchain_send_recipient_placeholder">nome, NIP-05 ou npub</string>
<string name="onchain_send_change_recipient">Alterar destinatário</string>
<string name="onchain_send_amount">Valor</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="onchain_send_min_warning">O zap on-chain mínimo é %1$s sats — valores menores são consumidos pelas taxas dos mineradores. Use um zap Lightning em vez disso.</string>
<string name="onchain_send_comment_label">Comentário (opcional)</string>
<string name="onchain_send_priority">Prioridade</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_loading_fees">Carregando estimativas de taxa…</string>
<string name="onchain_send_button_amount">Enviar %1$s sats</string>
<string name="onchain_send_dont_split">Não dividir — pagar a apenas um destinatário</string>
@@ -1745,6 +1805,7 @@
<string name="onchain_send_result_transaction">Transação</string>
<string name="onchain_send_result_fee">Taxa</string>
<string name="onchain_send_result_change">Troco</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_done">Concluído</string>
<string name="onchain_send_failed_at">Falhou em: %1$s</string>
<string name="onchain_send_broadcast_no_receipt">O pagamento foi transmitido (tx %1$s), mas o recibo não foi publicado.</string>
@@ -1838,6 +1899,7 @@
<string name="article_description_placeholder">Uma breve descrição do seu artigo\u2026</string>
<string name="url_slug">Slug da URL</string>
<string name="url_slug_placeholder">slug-url-do-artigo</string>
<string name="tags_label"># Tags</string>
<string name="add_a_tag">Adicionar uma tag\u2026</string>
<string name="start_writing_article">Comece a escrever seu artigo\u2026</string>
<string name="reply_description">Responder</string>
@@ -1866,9 +1928,11 @@
<string name="reactions_settings_reorder">Reordenar</string>
<string name="reactions_settings_reply">Responder</string>
<string name="reactions_settings_reply_description">Responder a esta nota</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_boost_description">Repostar ou citar esta nota</string>
<string name="reactions_settings_like">Curtir</string>
<string name="reactions_settings_like_description">Reagir a esta nota com um emoji</string>
<string name="reactions_settings_zap">Zap</string>
<string name="reactions_settings_zap_description">Enviar um pagamento lightning ao autor</string>
<string name="reactions_settings_share">Compartilhar</string>
<string name="reactions_settings_share_description">Compartilhar esta nota externamente</string>
@@ -1888,6 +1952,7 @@
<string name="video_player_settings_action_share_description">Compartilhar o link do vídeo externamente</string>
<string name="video_player_settings_action_download">Salvar no telefone</string>
<string name="video_player_settings_action_download_description">Baixar o vídeo para o dispositivo (oculto em transmissões ao vivo)</string>
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
<string name="video_player_settings_action_pip_description">Reproduzir o vídeo em uma janela flutuante (oculto se não suportado)</string>
<string name="video_player_settings_action_cast">Transmitir para dispositivo</string>
<string name="video_player_settings_action_cast_description">Envia o vídeo para um receptor Chromecast na sua Wi-Fi (oculto para arquivos locais)</string>
@@ -1939,6 +2004,7 @@
<string name="buzz_system_message_deleted">%1$s excluiu uma mensagem</string>
<string name="buzz_system_message_deleted_reason">%1$s excluiu uma mensagem: %2$s</string>
<string name="buzz_system_dm_created">%1$s iniciou esta conversa</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_huddle_started">🔊 %1$s iniciou um huddle</string>
<string name="buzz_huddle_joined">🔊 %1$s entrou no huddle</string>
<string name="buzz_huddle_left">🔊 %1$s saiu do huddle</string>
@@ -1951,9 +2017,11 @@
<string name="buzz_job_error">⚠ Erro no trabalho</string>
<string name="buzz_forum_upvoted">▲ %1$s votou a favor de um post</string>
<string name="buzz_forum_downvoted">▼ %1$s votou contra um post</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">Nenhum canvas foi compartilhado neste espaço de trabalho ainda.</string>
<string name="buzz_canvas_edit">Editar canvas</string>
<string name="buzz_canvas_save">Salvar canvas</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_editing_banner">Editando mensagem</string>
<string name="message_edited">(editado)</string>
<string name="buzz_typing_one">%1$s está digitando…</string>
@@ -2005,6 +2073,7 @@
<string name="buzz_invite_share">Compartilhar</string>
<string name="buzz_invite_copy">Copiar</string>
<string name="buzz_invite_error_title">Não foi possível criar o convite</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_dm_section_empty">Nenhuma conversa ainda</string>
<string name="chat_delivery_details_title">Entrega da mensagem</string>
<string name="close">Fechar</string>
@@ -2052,6 +2121,8 @@
<string name="restricted_writes">Gravações Restritas</string>
<string name="forked_from">Forkado de</string>
<string name="git_clone_address">Clonar:</string>
<string name="git_branch">Branch</string>
<string name="git_commit">Commit</string>
<string name="git_merge_base">Base de merge</string>
<string name="git_pr_update_description">O pull request foi atualizado para um novo commit.</string>
<string name="git_repo_tab_issues">Tickets</string>
@@ -2060,6 +2131,8 @@
<string name="git_repo_filter_closed">Fechados &amp; Resolvidos</string>
<string name="git_repo_label_all">Todos</string>
<string name="git_repo_bookmark">Marcar repositório</string>
<string name="git_repo_stat_branches">Branches</string>
<string name="git_repo_stat_tags">Tags</string>
<string name="git_repo_stat_files">Arquivos</string>
<string name="git_repo_stat_updated">Atualizado</string>
<string name="git_repo_recent_activity">Atividade recente</string>
@@ -2079,8 +2152,11 @@
<string name="git_repo_root">raiz</string>
<string name="git_repo_retry">Tentar novamente</string>
<string name="git_repo_default_branch">padrão</string>
<string name="git_repo_branches">Branches</string>
<string name="git_repo_tags">Tags</string>
<string name="git_repo_search_files">Pesquisar arquivos…</string>
<string name="git_repo_no_search_results">Nenhum arquivo correspondente.</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_no_commits">Nenhum histórico de commits disponível.</string>
<string name="git_repo_copy_file">Copiar conteúdo do arquivo</string>
<string name="git_repo_plain_text">Texto</string>
@@ -2146,6 +2222,7 @@
<string name="dvm_home_status_payment_required">Este algoritmo de feed requer pagamento</string>
<string name="dvm_home_status_error">O algoritmo de feed retornou um erro</string>
<string name="dvm_home_retry">Tentar novamente</string>
<string name="dvm_offline">Offline</string>
<string name="dvm_offline_banner">Este algoritmo de feed não enviou sinal de atividade recentemente e pode estar fora do ar</string>
<string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string>
<string name="group_relay">Relé de chat</string>
@@ -2155,6 +2232,7 @@
<string name="search_scope_all">Tudo</string>
<string name="search_scope_people">Pessoas</string>
<string name="search_scope_notes">Notas</string>
<string name="search_source_local">Local</string>
<string name="search_source_relays">Relés</string>
<string name="search_follows_only">Apenas seguidos</string>
<string name="search_sort_newest">Mais recentes</string>
@@ -2172,6 +2250,7 @@
<string name="search_filters_section_source">Fonte</string>
<string name="search_filters_section_sort">Ordenar por</string>
<string name="search_filters_open">Filtros</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="search_languages">Buscar idiomas</string>
<string name="add_language">Adicionar idioma</string>
<string name="add_language_pair">Adicionar par de idiomas</string>
@@ -2231,6 +2310,7 @@
<string name="timeout">Tempo esgotado</string>
<string name="retrying">Tentando novamente…</string>
<string name="dismiss">Dispensar</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Enviando</string>
<string name="broadcasting_name">Enviando %1$s</string>
<string name="broadcasting_number_events">Enviando %1$d eventos…</string>
@@ -2315,6 +2395,7 @@
<string name="event_sync_sending_to">Enviando para</string>
<string name="event_sync_outbox_relays">Saída</string>
<string name="event_sync_inbox_relays">Entrada</string>
<string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_activity_log">Verificando (%1$d relays)</string>
<string name="event_sync_activity_log_finished">Concluído (%1$d relays)</string>
<string name="event_sync_log_sent">env. %1$s</string>
@@ -2334,6 +2415,7 @@
<string name="cashu_move_coins_withdraw_ln">Sacar via Lightning…</string>
<string name="cashu_move_coins_export_token">Exportar como token Cashu…</string>
<string name="cashu_move_coins_working">Movendo moedas…</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Conectando</string>
<string name="event_sync_status_downloading">Baixando</string>
<string name="event_sync_status_error">Erro</string>
@@ -2343,6 +2425,7 @@
<string name="profile_actions_dialog_title">Ações do perfil</string>
<string name="media_actions_dialog_title">Ações de mídia</string>
<string name="playback_actions_dialog_title">Reprodução</string>
<string name="video_quality_auto">Auto</string>
<string name="edit_nickname">Editar apelido</string>
<string name="nickname_private">Visível apenas para você</string>
<string name="cast_to_device_dialog_title">Transmitir para…</string>
@@ -2355,7 +2438,9 @@
<string name="hls_description_label">Descrição</string>
<string name="hls_description_placeholder">Sobre o que é este vídeo?</string>
<string name="hls_content_warning_reason_placeholder">Motivo (opcional)</string>
<string name="hls_codec_label">Codec</string>
<string name="hls_codec_h265">H.265 (melhor compressão)</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_fallback_notice">H.265 não disponível neste dispositivo — usando H.264.</string>
<string name="hls_renditions_label">Versões</string>
<string name="hls_renditions_source_format">Resolução da origem: %1$d×%2$d</string>
@@ -2393,12 +2478,14 @@
<string name="event_sync_date_filter_until">Até</string>
<string name="event_sync_date_filter_now">Agora</string>
<string name="event_sync_date_filter_all_time">Todo o período</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://exemplo.com</string>
<string name="web_bookmark_title_label">Título</string>
<string name="web_bookmark_title_placeholder">Título do marcador</string>
<string name="web_bookmark_description_label">Descrição</string>
<string name="web_bookmark_description_placeholder">Uma breve descrição</string>
<string name="web_bookmark_tags_label">Tags (separadas por vírgula)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Salvar</string>
<string name="web_bookmark_delete">Excluir</string>
<string name="web_bookmark_delete_confirm">Excluir este marcador web?</string>
@@ -2407,6 +2494,7 @@
<string name="fundraiser_ends">Termina %1$s</string>
<string name="fundraiser_onchain_donation">Doação on-chain</string>
<string name="goal_amount_label">Valor da meta (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Descreva sua meta</string>
<string name="goal_description_placeholder">Para que você está arrecadando fundos?</string>
<string name="goal_summary_label">Resumo curto</string>
@@ -2519,6 +2607,7 @@
<string name="ai_tone_elaborate">Elaborar</string>
<string name="ai_tone_friendly">Amigável</string>
<string name="ai_tone_professional">Profissional</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="emoji_packs_title">Pacotes de emojis</string>
<string name="emoji_pack_management_title">Adicionar à lista de emojis</string>
<string name="add_to_emoji_list">Adicionar à minha lista de emojis</string>
@@ -2533,6 +2622,7 @@
<string name="emoji_pack_upload_image_cta">Enviar imagem de capa</string>
<string name="emoji_pack_upload_image_hint">Escolha uma imagem quadrada para representar este pacote de emojis.</string>
<string name="no_emoji_packs">Você ainda não tem nenhum pacote de emoji</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">Minha lista de emojis</string>
<string name="my_emoji_list_explainer">Pacotes de emojis que você adicionou à sua seleção (NIP-51 kind 10030)</string>
<string name="my_emoji_list_empty">Você ainda não adicionou nenhum pacote de emojis à sua lista</string>
@@ -2546,6 +2636,7 @@
<string name="emoji_pack_actions_dialog_title">Ações do pacote de emoji</string>
<string name="emoji_private_toggle">Privado</string>
<string name="emoji_private_badge">Emoji privado</string>
<string name="gif">Gif</string>
<string name="community_rules_violation_icon">Violação das regras da comunidade</string>
<string name="community_rules_violation_author_denied">Você está na lista de bloqueados desta comunidade. Os posts não serão aceitos.</string>
<string name="community_rules_violation_kind_not_allowed">Esta comunidade não aceita eventos do tipo %1$d.</string>
@@ -2566,6 +2657,7 @@
<string name="marmot_disband_group_confirm">Dissolver “%1$s” para todos? A conversa termina para todos os membros e não pode ser reaberta — seria preciso criar um novo grupo.</string>
<string name="marmot_disband_group_action">Dissolver</string>
<string name="marmot_avatar_url">Link do avatar</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">Um link https que todos os membros conseguem carregar. Deixe vazio para usar a imagem enviada.</string>
<string name="marmot_legacy_group_no_avatar_url">Este grupo foi criado antes do suporte a avatares por link, então só pode usar uma imagem enviada. Grupos existentes não podem ser atualizados — crie um novo grupo para usar um link.</string>
<string name="marmot_enable_encrypted_media">Usar anexos criptografados</string>
@@ -2573,6 +2665,7 @@
<string name="marmot_legacy_group_no_disband">Este grupo foi criado antes do suporte à dissolução e não pode ser dissolvido. Você ainda pode sair dele ou remover todos os outros membros. Grupos existentes não podem ser atualizados.</string>
<string name="marmot_group_default_name">Grupo Marmot</string>
<string name="marmot_group_fallback_name">Grupo %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="marmot_tab_known">Conhecidos</string>
<string name="marmot_tab_known_count">Conhecidos (%1$d)</string>
<string name="marmot_tab_new_requests">Novos pedidos</string>
@@ -2641,6 +2734,7 @@
<string name="dvm_pay_invoice_from_dvm">Pagar fatura do DVM</string>
<string name="dvm_pay_amount_to_dvm">Pagar %1$s sats ao DVM</string>
<string name="geohash_chat_open">Abrir chat de localização</string>
<string name="buzz_job_board_title">Backlog</string>
<string name="buzz_workflow_runs_title">Execuções de fluxo de trabalho</string>
<string name="buzz_agent_work_title">Trabalho do agente</string>
<string name="buzz_workflow_new_run">Nova execução</string>
@@ -2729,99 +2823,5 @@
<string name="buzz_persona_publishing">Publicando…</string>
<string name="buzz_persona_publish">Publicar persona</string>
<string name="profile_card_follows_you">Segue você</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="amount_in_bits">%1$s bits</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_job_board_title">Backlog</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="dvm_offline">Offline</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="error_dialog_button_ok">OK</string>
<string name="event_sync_dm_relays">DMs</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="feed">Feed</string>
<string name="gif">Gif</string>
<string name="git_branch">Branch</string>
<string name="git_commit">Commit</string>
<string name="git_repo_branches">Branches</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_stat_branches">Branches</string>
<string name="git_repo_stat_tags">Tags</string>
<string name="git_repo_tags">Tags</string>
<string name="goal_amount_placeholder">100000</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_label">Codec</string>
<string name="interest_set_hashtag_count">%1$d hashtag(s)</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="malware">Malware</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip82_section_links">Links</string>
<string name="nip82_version_label">v%1$s</string>
<string name="not_available_acronym">N/A</string>
<string name="nutzap">Nutzap</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="original">original</string>
<string name="picture_in_picture">Picture-in-Picture</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="platform_web">Web</string>
<string name="podcast_bookmarks">Podcasts</string>
<string name="podcast_role_editor">Editor</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="post_not_found_short">👀</string>
<string name="profile_apps_header">Apps · %1$d</string>
<string name="profile_card_bot">Bot</string>
<string name="reactions_settings_boost">Boost</string>
<string name="reactions_settings_zap">Zap</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="search_source_local">Local</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="security_unlimited"></string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="share_of">%1$d/%2$d</string>
<string name="tags_label"># Tags</string>
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
<string name="video_quality_auto">Auto</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_sats">sats</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_url_label">URL</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_volume">Volume</string>
<string name="health_connect_rationale_headline">O Amethyst lê treinos concluídos para preencher previamente uma publicação de treino para você.</string>
<string name="health_connect_rationale_title">Health Connect e Amethyst</string>
<string name="health_connect_rationale_intro">O Amethyst é um cliente social do Nostr. A seção Treinos permite publicar um resumo de um treino concluído nos relays do Nostr que você escolher, para que quem segue você veja o que você fez. Em vez de digitar cada número à mão, o Amethyst pode ler o treino que seu relógio ou aplicativo de fitness já salvou no Health Connect e preencher a publicação previamente. Você sempre vê a publicação preenchida e decide se quer publicá-la.</string>
<string name="health_connect_rationale_what_title">O que o Amethyst lê e por quê</string>
<string name="health_connect_rationale_exercise">Exercício · qual atividade você fez, quando começou e quanto tempo durou — é o treino em si, além do título e da duração da publicação.</string>
<string name="health_connect_rationale_steps">Passos · a contagem de passos de uma corrida, caminhada ou trilha.</string>
<string name="health_connect_rationale_distance">Distância · o quanto você percorreu, exibido como a distância da corrida, pedalada, caminhada ou natação.</string>
<string name="health_connect_rationale_calories">Calorias ativas e totais · a energia gasta no treino. As calorias ativas são usadas quando sua fonte as registra; as calorias totais são a alternativa para fontes que registram apenas a energia total.</string>
<string name="health_connect_rationale_elevation">Elevação · o quanto você subiu, que é o que distingue um percurso plano de um acidentado.</string>
<string name="health_connect_rationale_heart_rate">Frequência cardíaca · a frequência média e máxima durante o treino, a medida padrão do esforço.</string>
<string name="health_connect_rationale_limits_title">O que o Amethyst não faz</string>
<string name="health_connect_rationale_limit_window">Lê apenas treinos concluídos nos últimos 7 dias e somente enquanto o editor de Treinos está aberto. Nunca lê em segundo plano.</string>
<string name="health_connect_rationale_limit_publish">Nada sai do seu telefone até você tocar em uma sugestão e publicar por conta própria. O Amethyst não tem servidor: a publicação vai para os relays do Nostr que você configurou.</string>
<string name="health_connect_rationale_limit_write">Nunca grava nada no Health Connect e nunca solicita seu trajeto de exercício, localização ou qualquer outro tipo de dado de saúde.</string>
<string name="health_connect_rationale_limit_optional">Todo o recurso é opcional. Desative-o em Configurações → Configurações de composição, ou revogue as permissões no Health Connect a qualquer momento — o restante do Amethyst continua funcionando.</string>
<string name="health_connect_rationale_privacy_policy">Ler a política de privacidade completa</string>
<string name="workout_suggestion_connect_details">O que o Amethyst lê</string>
</resources>
@@ -195,6 +195,7 @@
<string name="post_was_hidden">Detta inlägg doldes eftersom det nämner dina dolda användare eller ord</string>
<string name="post_was_flagged_as_inappropriate_by">Inlägget flaggades som olämpligt av</string>
<string name="post_not_found">Inlägg hittades inte</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanal bild</string>
<string name="referenced_event_not_found">Refererad händelse hittades inte</string>
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
@@ -230,6 +231,7 @@
<string name="chat_raid_is_raiding">gör en raid</string>
<string name="chat_clip_created_a_clip">skapade ett klipp</string>
<string name="edited">redigerat</string>
<string name="original">original</string>
<string name="quote">Citera</string>
<string name="fork">Förgrening</string>
<string name="new_amount_in_sats">Nytt belopp i Sats</string>
@@ -249,11 +251,13 @@
<string name="clink_offer_amount_sats">Belopp (sats)</string>
<string name="clink_offer_amount_range">Tillåtet intervall: %1$s%2$s sats</string>
<string name="clink_offer_label">CLINK-erbjudande (noffer)</string>
<string name="clink_budget_set">Budget</string>
<string name="clink_budget_title">Utgiftsbudget</string>
<string name="clink_budget_amount_sats">Belopp (sats)</string>
<string name="clink_budget_request">Begär</string>
<string name="clink_budget_approved">Budget godkänd</string>
<string name="clink_debit_pay_only">Endast betalning</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_add_clink_description">Betala och zappa från en plånbok som har förauktoriserat ditt konto. Endast utgifter — inget saldo eller historik.</string>
<string name="wallet_add_clink_invalid">Ogiltig CLINK-debitpekare. En ndebit1…-sträng förväntades.</string>
<string name="wallet_paste_ndebit">Klistra in ndebit-pekare</string>
@@ -263,7 +267,10 @@
<string name="send_payment_source_external">En annan plånboksapp</string>
<string name="send_payment_source_onchain_wallet">On-chain-plånbok</string>
<string name="send_payment_source_cashu_wallet">Cashu-plånbok</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_clink">CLINK-erbjudande</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_amount">Belopp</string>
<string name="send_payment_fixed_price">Pris satt av erbjudandet</string>
<string name="send_payment_receipt_section">Zap-kvitto</string>
@@ -296,6 +303,7 @@
<string name="secret_note_to_receiver">Hemlig anteckning till mottagaren</string>
<string name="secret_note_to_receiver_placeholder">Mitt dolda inlägg</string>
<string name="secret_visible_text">Synligt Prefix</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="secret_add_to_text">Lägg till inlägg</string>
<string name="article">Artikel</string>
<string name="channel_name">Kanal Namn</string>
@@ -314,6 +322,7 @@
<string name="username">Användarnamn</string>
<string name="about_me">Om mig</string>
<string name="avatar_url">Profilbild URL</string>
<string name="banner_url">Banner URL</string>
<string name="website_url">Websida URL</string>
<string name="pronouns">Pronomen</string>
<string name="stale_relay_hint_label">Källrelän kan vara föråldrade</string>
@@ -424,6 +433,7 @@
<string name="dm_sender_reported_hide">Dölj</string>
<string name="dm_sender_reported_subtitle">Någon du följer har flaggat detta konto</string>
<string name="dm_sender_reported_icon">Anmäld</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="error_loading_replies">Det gick inte att läsa in svar: </string>
<string name="try_again">Försök igen</string>
<string name="notification_feed_is_empty">Inga aviseringar ännu.</string>
@@ -604,6 +614,12 @@
<string name="app_definition_related">Relaterat</string>
<string name="app_definition_implements">Implementerar</string>
<string name="app_definition_supported_nips">NIP:er som stöds</string>
<string name="app_definition_via">via %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_kind_app">App</string>
<string name="platform_web">Web</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="workout">Träningspass</string>
<string name="workout_title">Titel</string>
<string name="workout_duration">Varaktighet</string>
@@ -628,7 +644,25 @@
<string name="workout_suggestion_connect_title">Dela dina träningspass</string>
<string name="workout_suggestion_connect_message">Låt Amethyst läsa avslutade träningspass från Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) och föreslå ett inlägg.</string>
<string name="workout_suggestion_connect_button">Anslut</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="workout_from_health_connect">Från Health Connect</string>
<string name="workout_suggestion_connect_details">Vad Amethyst läser</string>
<string name="health_connect_rationale_title">Health Connect och Amethyst</string>
<string name="health_connect_rationale_headline">Amethyst läser avslutade träningspass för att kunna förifylla ett träningsinlägg åt dig.</string>
<string name="health_connect_rationale_intro">Amethyst är en social Nostr-klient. I avsnittet Träningspass kan du publicera en sammanfattning av ett avslutat träningspass till de Nostr-reläer du väljer, så att de som följer dig kan se vad du har gjort. I stället för att skriva in varje siffra för hand kan Amethyst läsa det träningspass som din klocka eller träningsapp redan har sparat i Health Connect och förifylla inlägget. Du ser alltid det förifyllda inlägget och avgör själv om det ska publiceras.</string>
<string name="health_connect_rationale_what_title">Vad Amethyst läser och varför</string>
<string name="health_connect_rationale_exercise">Övning · vilken aktivitet du gjorde, när den började och hur länge den varade — det är själva träningspasset och även inläggets titel och varaktighet.</string>
<string name="health_connect_rationale_distance">Distans · hur långt du tog dig, som visas som distansen för löprundan, cykelturen, promenaden eller simningen.</string>
<string name="health_connect_rationale_calories">Aktiva och totala kalorier · energin som träningspasset förbrände. Aktiva kalorier används när din källa registrerar dem; totala kalorier är reserven för källor som bara registrerar total energi.</string>
<string name="health_connect_rationale_heart_rate">Puls · genomsnittlig och maximal puls under träningspasset, det vanliga måttet på hur ansträngande det var.</string>
<string name="health_connect_rationale_steps">Steg · antalet steg under en löprunda, promenad eller vandring.</string>
<string name="health_connect_rationale_elevation">Höjdmeter · hur mycket du klättrade, vilket skiljer en platt tur från en kuperad.</string>
<string name="health_connect_rationale_limits_title">Vad Amethyst inte gör</string>
<string name="health_connect_rationale_limit_window">Läser bara träningspass som avslutades de senaste 7 dagarna, och bara medan träningsredigeraren är öppen. Den läser aldrig i bakgrunden.</string>
<string name="health_connect_rationale_limit_write">Skriver aldrig något till Health Connect och begär aldrig din träningsrutt, plats eller någon annan typ av hälsodata.</string>
<string name="health_connect_rationale_limit_publish">Ingenting lämnar din telefon förrän du trycker på ett förslag och publicerar inlägget själv. Amethyst har ingen server: inlägget går till de Nostr-reläer du har ställt in.</string>
<string name="health_connect_rationale_limit_optional">Hela funktionen är valfri. Stäng av den under Inställningar → Skrivinställningar, eller återkalla behörigheterna i Health Connect när som helst — resten av Amethyst fortsätter att fungera.</string>
<string name="health_connect_rationale_privacy_policy">Läs hela integritetspolicyn</string>
<string name="nsite_none_found">Inga nSites hittades ännu.</string>
<string name="browser_reload">Ladda om</string>
<string name="browser_unsupported">Den inbyggda webbläsaren kräver Android 11 eller senare.</string>
@@ -703,6 +737,7 @@
<string name="nip46_signer_background_hint">Amethyst håller en bakgrundsanslutning (visas som en pågående notifiering) så att den kan besvara signeringsförfrågningar när den är stängd.</string>
<string name="nip46_signer_hero_title">Signera åt andra appar</string>
<string name="nip46_signer_turn_on">Slå på fjärrsignatären</string>
<string name="nip46_signer_live">Live</string>
<string name="nip46_signer_batch_remember">Kom ihåg dessa för varje app</string>
<string name="nip46_signer_batch_signing_as">som %1$s</string>
<string name="nip46_signer_batch_allow">Tillåt %1$d</string>
@@ -714,7 +749,9 @@
<string name="nip46_signer_act_decrypted">Dekrypterade ett meddelande</string>
<string name="nip46_signer_act_shared_pubkey">Delade din publika nyckel</string>
<string name="nip46_signer_act_connected">Ansluten</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Listade reläer</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_scan_caption">Skanna för att ansluta en app till din nyckel</string>
<string name="nip46_signer_scan_connect">Skanna en kod</string>
<string name="nip46_signer_paste_link">Klistra in en länk istället</string>
@@ -762,6 +799,7 @@
<string name="relay_auth_no_recent">Inget relä har bett dig logga in än.</string>
<string name="relay_auth_ago">för %1$s sedan</string>
<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>
<string name="nip82_section_about">Om</string>
<string name="nip82_section_platforms">Plattformar</string>
@@ -799,6 +837,7 @@
<string name="nest_lobby_no_listeners">Ingen är i rummet just nu.</string>
<string name="nest_lobby_listeners_count">%1$d lyssnar</string>
<string name="nest_role_host">Värd</string>
<string name="nest_role_moderator">Moderator</string>
<string name="nest_state_muted">Mikrofon avstängd</string>
<string name="nest_state_speaking">Talar nu</string>
<string name="nest_state_mic_open">Mikrofon på</string>
@@ -806,6 +845,7 @@
<string name="nest_leave_host_body">Du är värden. Att stänga rummet kopplar bort alla. Välj "Bara lämna" om du vill komma tillbaka senare — rummet stängs automatiskt efter 8 timmars inaktivitet.</string>
<string name="nest_leave_host_close">Stäng rum</string>
<string name="nest_leave_host_just_leave">Bara lämna</string>
<string name="nest_live_chip">LIVE</string>
<string name="nest_tab_chat">Chatt</string>
<string name="nest_tab_audience">Publik</string>
<string name="nest_tab_hands">Händer</string>
@@ -876,13 +916,16 @@
<string name="nests_servers_empty">Inga nest-servrar än. Lägg till en nedan eller välj en rekommenderad server.</string>
<string name="podcast_view_episodes">Visa avsnitt</string>
<string name="podcast_no_episodes">Inga avsnitt hittades än</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_trailer_season">Säsong %1$d</string>
<string name="podcast_explicit">Explicit</string>
<string name="podcast_completed">Slutförd</string>
<string name="podcast_support_show">Stöd podden</string>
<string name="podcast_by_author">av %1$s</string>
<string name="podcast_season_episode">S%1$d · A%2$d</string>
<string name="podcast_episode_number">Avs %1$d</string>
<string name="podcast_season">Säsong %1$d</string>
<string name="podcast_video">Video</string>
<string name="podcast_transcript">Transkription</string>
<string name="podcast_chapters">Kapitel</string>
<string name="podcast_hosts_and_guests">Värdar &amp; gäster</string>
@@ -892,6 +935,7 @@
<string name="podcast_role_editor">Redaktör</string>
<string name="podcast_author_verified">Verifierad författare</string>
<string name="podcast_value_stream">Streama sats</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_value_stream_hint">Skickar värde automatiskt medan du lyssnar.</string>
<string name="podcast_value_streamed_total">Streamade %1$d sats denna session</string>
<string name="podcast_episode_title_label">Titel</string>
@@ -934,10 +978,12 @@
<string name="podcast_value_lnaddress">Lightning-adress</string>
<string name="podcast_value_lnaddress_hint">namn@example.com</string>
<string name="podcast_value_node_pubkey">Nodens publika nyckel</string>
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
<string name="podcast_value_weight">Vikt</string>
<string name="podcast_value_fee">Avgift</string>
<string name="music_track_title_label">Titel</string>
<string name="music_track_title_placeholder">Spårtitel</string>
<string name="music_track_artist_label">Artist</string>
<string name="music_track_artist_placeholder">Artistens namn</string>
<string name="music_track_album_label">Album (valfritt)</string>
<string name="music_track_duration_label">Längd i sekunder (valfritt)</string>
@@ -962,6 +1008,7 @@
<string name="add_hashtag_label">Lägg till hashtag</string>
<string name="add_hashtag_label_title">Lägg till en hashtag</string>
<string name="add_hashtag_label_explainer">Tagga det här inlägget offentligt med en hashtag (NIP-32-etikett). De som följer dig ser det i den hashtaggens flöde.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Lägg till</string>
<string name="hashtag_label_added_by">tillagd av</string>
<string name="pinned_notes">Fästa anteckningar</string>
@@ -1020,6 +1067,7 @@
<string name="quick_zap_amounts_explainer">Visas när du trycker på Zap-knappen. Tryck på ett belopp för att ta bort det. Om du lämnar det tomt öppnas en dialog för att ange ett belopp varje gång.</string>
<string name="send_onchain_instead">Skicka on-chain istället</string>
<string name="reload_mint_title">Fyll på mint</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="reload_mint_topup_label">Påfyllningsbelopp</string>
<string name="reload_mint_section_to">Mint att fylla på</string>
<string name="reload_mint_section_from">Medel från</string>
@@ -1231,6 +1279,7 @@
<string name="show_sensitive_content_explainer">Visar ett varningsmeddelande när författaren till inlägget markerar det som känsligt</string>
<string name="max_hashtag_limit_title">Max hashtags per inlägg</string>
<string name="max_hashtag_limit_explainer">Döljer inlägg med fler hashtaggar än denna gräns. Sätt till 0 för att inaktivera</string>
<string name="security_unlimited"></string>
<string name="new_reaction_symbol">Ny reaktionssymbol</string>
<string name="zapraiser">Zap insamling</string>
<string name="zapraiser_explainer">Lägger till en målsumma av sats för detta inlägg. Stödjande klienter kan visa detta som en framstegsindikator för att stimulera donationer</string>
@@ -1240,6 +1289,7 @@
<string name="owner">Ägare</string>
<string name="used_by">Används av</string>
<string name="self">Service-nyckel</string>
<string name="version">Version</string>
<string name="software">Programvara</string>
<string name="contact">Kontakt</string>
<string name="supports">Stödda NIPs</string>
@@ -1261,6 +1311,7 @@
<string name="all_languages">Alla språk</string>
<string name="posting_policy">Publiceringspolicy</string>
<string name="terms_and_conditions">Villkor &amp; bestämmelser</string>
<string name="not_available_acronym">N/A</string>
<string name="relay_error_messages">Fel och meddelanden från detta relä</string>
<string name="relay_monitor_reports">Reläövervakningsrapporter</string>
<string name="relay_active_subscriptions">Aktiva prenumerationer</string>
@@ -1292,9 +1343,12 @@
<string name="max_filters_per_sub">Max. filter per prenumeration</string>
<string name="max_limit_events_returning">Maxgräns (returnerade händelser)</string>
<string name="max_subid_length">Max. SubID-längd</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_redeem">Inlösen</string>
<string name="cashu_copy_token">Kopiera Token</string>
<string name="quick_actions_open_in_another_app">Öppna i annan app</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">SLUTADE</string>
<string name="live_stream_planned_tag">PLANERAD</string>
<string name="live_stream_private_tag">PRIVAT</string>
@@ -1392,6 +1446,7 @@
<string name="relay_group_join_confirm">Gå med</string>
<string name="relay_group_pending">Begärd</string>
<string name="relay_group_role_admin">Administratör</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="relay_group_role_member">Medlem</string>
<string name="relay_group_members_title">Medlemmar</string>
<string name="relay_group_make_admin">Gör till admin</string>
@@ -1414,6 +1469,8 @@
<string name="relay_group_invite_preparing">Förbereder inbjudan…</string>
<string name="relay_group_badge_private">Privat</string>
<string name="relay_group_badge_invite_only">Endast inbjudan</string>
<string name="relay_group_badge_live">LIVE</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Ange en giltig relä-URL (wss://…).</string>
<string name="relay_group_field_name">Namn</string>
<string name="relay_group_field_about">Beskrivning</string>
@@ -1540,6 +1597,7 @@
<string name="send_the_seller_a_message">Skicka ett meddelande till säljaren</string>
<string name="hi_there_is_this_still_available">Hallå där, är detta fortfarande tillgängligt?</string>
<string name="classifieds_title">Titel</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="classifieds_condition">Skick</string>
<string name="classifieds_category">Kategori</string>
<string name="classifieds_price">Pris (i Sats)</string>
@@ -1568,6 +1626,7 @@
<string name="wallet_send">Skicka</string>
<string name="wallet_receive">Ta emot</string>
<string name="wallet_transactions">Transaktioner</string>
<string name="wallet_sats">sats</string>
<string name="wallet_paste_invoice">Klistra in en BOLT-11-faktura</string>
<string name="wallet_pay">Betala</string>
<string name="wallet_payment_success">Betalning lyckades</string>
@@ -1584,6 +1643,7 @@
<string name="wallet_outgoing">Skickad</string>
<string name="wallet_refresh">Uppdatera</string>
<string name="wallet_filter_all">Alla</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_filter_non_zaps">Övriga</string>
<string name="wallet_add">Lägg till plånbok</string>
<string name="wallet_default">Standard</string>
@@ -1629,10 +1689,12 @@
<string name="cashu_wizard_set_main_first">Ange din huvudplånbok först, återhämta sedan medel från de äldre.</string>
<string name="cashu_wizard_adopting">Konfigurerar din plånbok…</string>
<string name="cashu_wizard_retry">Sök igen</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="cashu_created_title">Plånbok skapad</string>
<string name="cashu_created_subtitle">Välj nu några mints som ska hosta dina sats.</string>
<string name="cashu_created_pick_mints">Välj mints</string>
<string name="cashu_balance">Saldo</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_mint_url">Mint-URL</string>
<string name="cashu_remove_mint">Ta bort mint</string>
<string name="cashu_add_mint">Lägg till mint</string>
@@ -1693,7 +1755,9 @@
<string name="cashu_getting_quote">Ber mint om en offert…</string>
<string name="cashu_verify">Verifiera</string>
<string name="cashu_mint_reachable">✓ Mint är nåbar</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mint_unreachable">Kunde inte nå mint: %1$s</string>
<string name="nutzap">Nutzap</string>
<string name="cashu_create_token">Skapa token</string>
<string name="cashu_redeem_button">Lös in</string>
<string name="cashu_done">Klar</string>
@@ -1728,9 +1792,11 @@
<string name="onchain_send_recipient_placeholder">namn, NIP-05 eller npub</string>
<string name="onchain_send_change_recipient">Ändra mottagare</string>
<string name="onchain_send_amount">Belopp</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="onchain_send_min_warning">Minsta on-chain-zap är %1$s sats — mindre belopp äts upp av brytaravgifter. Använd en Lightning-zap istället.</string>
<string name="onchain_send_comment_label">Kommentar (valfritt)</string>
<string name="onchain_send_priority">Prioritet</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_loading_fees">Laddar avgiftsuppskattningar…</string>
<string name="onchain_send_button_amount">Skicka %1$s sats</string>
<string name="onchain_send_dont_split">Dela inte upp — betala en mottagare istället</string>
@@ -1739,6 +1805,7 @@
<string name="onchain_send_result_transaction">Transaktion</string>
<string name="onchain_send_result_fee">Avgift</string>
<string name="onchain_send_result_change">Växel</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_done">Klar</string>
<string name="onchain_send_failed_at">Misslyckades vid: %1$s</string>
<string name="onchain_send_broadcast_no_receipt">Betalningen sändes ut (tx %1$s) men kvittot publicerades inte.</string>
@@ -1865,6 +1932,7 @@
<string name="reactions_settings_boost_description">Reposta eller citera denna notis</string>
<string name="reactions_settings_like">Gilla</string>
<string name="reactions_settings_like_description">Reagera på denna notis med en emoji</string>
<string name="reactions_settings_zap">Zap</string>
<string name="reactions_settings_zap_description">Skicka en lightning-betalning till författaren</string>
<string name="reactions_settings_share">Dela</string>
<string name="reactions_settings_share_description">Dela denna notis externt</string>
@@ -1936,6 +2004,7 @@
<string name="buzz_system_message_deleted">%1$s tog bort ett meddelande</string>
<string name="buzz_system_message_deleted_reason">%1$s tog bort ett meddelande: %2$s</string>
<string name="buzz_system_dm_created">%1$s startade den här konversationen</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="buzz_huddle_started">🔊 %1$s startade en huddle</string>
<string name="buzz_huddle_joined">🔊 %1$s gick med i huddlen</string>
<string name="buzz_huddle_left">🔊 %1$s lämnade huddlen</string>
@@ -1948,9 +2017,11 @@
<string name="buzz_job_error">⚠ Jobbfel</string>
<string name="buzz_forum_upvoted">▲ %1$s röstade upp ett inlägg</string>
<string name="buzz_forum_downvoted">▼ %1$s röstade ner ett inlägg</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_canvas_empty">Ingen canvas har delats i den här arbetsytan än.</string>
<string name="buzz_canvas_edit">Redigera canvas</string>
<string name="buzz_canvas_save">Spara canvas</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_editing_banner">Redigerar meddelande</string>
<string name="message_edited">(redigerat)</string>
<string name="buzz_typing_one">%1$s skriver…</string>
@@ -2002,6 +2073,7 @@
<string name="buzz_invite_share">Dela</string>
<string name="buzz_invite_copy">Kopiera</string>
<string name="buzz_invite_error_title">Kunde inte skapa inbjudan</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_dm_section_empty">Inga konversationer än</string>
<string name="chat_delivery_details_title">Meddelandeleverans</string>
<string name="close">Stäng</string>
@@ -2044,11 +2116,13 @@
<string name="donate_now">Donera nu</string>
<string name="brought_to_you_by">tillhandahölls av:</string>
<string name="this_version_brought_to_you_by">Denna version tillhandahölls av:</string>
<string name="version_name">Version %1$s</string>
<string name="thank_you">Tack!</string>
<string name="restricted_writes">Begränsade skrivningar</string>
<string name="forked_from">Forkad från</string>
<string name="git_clone_address">Klona:</string>
<string name="git_branch">Gren</string>
<string name="git_commit">Commit</string>
<string name="git_merge_base">Mergebas</string>
<string name="git_pr_update_description">Uppdaterade pull request till en ny commit.</string>
<string name="git_repo_tab_issues">Ärenden</string>
@@ -2082,8 +2156,10 @@
<string name="git_repo_tags">Taggar</string>
<string name="git_repo_search_files">Sök filer…</string>
<string name="git_repo_no_search_results">Inga matchande filer.</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_no_commits">Ingen commit-historik tillgänglig.</string>
<string name="git_repo_copy_file">Kopiera filinnehåll</string>
<string name="git_repo_plain_text">Text</string>
<string name="git_new_issue_button">Ny</string>
<string name="git_new_issue_title">Nytt ärende</string>
<string name="git_new_issue_subject">Titel</string>
@@ -2146,6 +2222,7 @@
<string name="dvm_home_status_payment_required">Den här flödesalgoritmen kräver betalning</string>
<string name="dvm_home_status_error">Flödesalgoritmen returnerade ett fel</string>
<string name="dvm_home_retry">Försök igen</string>
<string name="dvm_offline">Offline</string>
<string name="dvm_offline_banner">Den här flödesalgoritmen har inte skickat någon livssignal på sistone och kan vara nere</string>
<string name="temporary_account">Logga ut när enheten låses</string>
<string name="group_relay">Chatt Relä</string>
@@ -2173,6 +2250,7 @@
<string name="search_filters_section_source">Källa</string>
<string name="search_filters_section_sort">Sortera efter</string>
<string name="search_filters_open">Filter</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="search_languages">Sök språk</string>
<string name="add_language">Lägg till språk</string>
<string name="add_language_pair">Lägg till språkpar</string>
@@ -2232,6 +2310,7 @@
<string name="timeout">Tidsgräns överskriden</string>
<string name="retrying">Försöker igen…</string>
<string name="dismiss">Avfärda</string>
<string name="share_of">%1$d/%2$d</string>
<string name="broadcasting">Sänder</string>
<string name="broadcasting_name">Sänder %1$s</string>
<string name="broadcasting_number_events">Sänder %1$d händelser…</string>
@@ -2336,6 +2415,7 @@
<string name="cashu_move_coins_withdraw_ln">Ta ut via Lightning…</string>
<string name="cashu_move_coins_export_token">Exportera som Cashu-token…</string>
<string name="cashu_move_coins_working">Flyttar mynt…</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Ansluter</string>
<string name="event_sync_status_downloading">Laddar ner</string>
<string name="event_sync_status_error">Fel</string>
@@ -2345,6 +2425,7 @@
<string name="profile_actions_dialog_title">Profilåtgärder</string>
<string name="media_actions_dialog_title">Medieåtgärder</string>
<string name="playback_actions_dialog_title">Uppspelning</string>
<string name="video_quality_auto">Auto</string>
<string name="edit_nickname">Redigera smeknamn</string>
<string name="nickname_private">Endast synligt för dig</string>
<string name="cast_to_device_dialog_title">Casta till…</string>
@@ -2357,7 +2438,9 @@
<string name="hls_description_label">Beskrivning</string>
<string name="hls_description_placeholder">Vad handlar den här videon om?</string>
<string name="hls_content_warning_reason_placeholder">Anledning (valfritt)</string>
<string name="hls_codec_label">Codec</string>
<string name="hls_codec_h265">H.265 (bättre komprimering)</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_fallback_notice">H.265 inte tillgängligt på den här enheten — växlar till H.264.</string>
<string name="hls_renditions_label">Versioner</string>
<string name="hls_renditions_source_format">Källupplösning: %1$d×%2$d</string>
@@ -2395,12 +2478,14 @@
<string name="event_sync_date_filter_until">Till</string>
<string name="event_sync_date_filter_now">Nu</string>
<string name="event_sync_date_filter_all_time">All tid</string>
<string name="web_bookmark_url_label">URL</string>
<string name="web_bookmark_url_placeholder">https://exempel.se</string>
<string name="web_bookmark_title_label">Titel</string>
<string name="web_bookmark_title_placeholder">Bokmärkets titel</string>
<string name="web_bookmark_description_label">Beskrivning</string>
<string name="web_bookmark_description_placeholder">En kort beskrivning</string>
<string name="web_bookmark_tags_label">Taggar (kommaseparerade)</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_save">Spara</string>
<string name="web_bookmark_delete">Radera</string>
<string name="web_bookmark_delete_confirm">Radera detta webbbokmärke?</string>
@@ -2409,11 +2494,13 @@
<string name="fundraiser_ends">Slutar %1$s</string>
<string name="fundraiser_onchain_donation">On-chain-donation</string>
<string name="goal_amount_label">Målbelopp (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Beskriv ditt mål</string>
<string name="goal_description_placeholder">Vad samlar du in pengar till?</string>
<string name="goal_summary_label">Kort sammanfattning</string>
<string name="goal_summary_placeholder">Kort beskrivning som visas i förhandsvisningar</string>
<string name="goal_image_label">Bild-URL (valfritt)</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="goal_website_label">Webbplats-URL (valfritt)</string>
<string name="goal_website_placeholder">https://exempel.se</string>
<string name="goal_set_deadline">Sätt en tidsfrist</string>
@@ -2520,6 +2607,7 @@
<string name="ai_tone_elaborate">Utveckla</string>
<string name="ai_tone_friendly">Vänlig</string>
<string name="ai_tone_professional">Professionell</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="emoji_packs_title">Emoji-paket</string>
<string name="emoji_pack_management_title">Lägg till i emoji-lista</string>
<string name="add_to_emoji_list">Lägg till i min emoji-lista</string>
@@ -2534,6 +2622,7 @@
<string name="emoji_pack_upload_image_cta">Ladda upp en omslagsbild</string>
<string name="emoji_pack_upload_image_hint">Välj en fyrkantig bild som ska representera detta emoji-paket.</string>
<string name="no_emoji_packs">Du har inga emoji-paket ännu</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">Min emoji-lista</string>
<string name="my_emoji_list_explainer">Emoji-paket som du har lagt till i ditt urval (NIP-51 kind 10030)</string>
<string name="my_emoji_list_empty">Du har inte lagt till några emoji-paket i din lista ännu</string>
@@ -2547,6 +2636,7 @@
<string name="emoji_pack_actions_dialog_title">Åtgärder för emoji-paket</string>
<string name="emoji_private_toggle">Privat</string>
<string name="emoji_private_badge">Privat emoji</string>
<string name="gif">Gif</string>
<string name="community_rules_violation_icon">Brott mot gemenskapens regler</string>
<string name="community_rules_violation_author_denied">Du står på den här gemenskapens blockeringslista. Inlägg kommer inte att accepteras.</string>
<string name="community_rules_violation_kind_not_allowed">Den här gemenskapen accepterar inte händelser av typ %1$d.</string>
@@ -2567,6 +2657,7 @@
<string name="marmot_disband_group_confirm">Upplös ”%1$s” för alla? Konversationen avslutas för alla medlemmar och kan inte öppnas igen — en ny grupp skulle behöva skapas.</string>
<string name="marmot_disband_group_action">Upplös</string>
<string name="marmot_avatar_url">Avatarlänk</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_avatar_url_footer">En https-länk som alla medlemmar kan läsa in. Lämna tomt för att använda den uppladdade bilden i stället.</string>
<string name="marmot_legacy_group_no_avatar_url">Den här gruppen skapades innan länkavatarer stöddes, så den kan bara använda en uppladdad bild. Befintliga grupper kan inte uppgraderas — skapa en ny grupp för att använda en länk.</string>
<string name="marmot_enable_encrypted_media">Använd krypterade bilagor</string>
@@ -2574,6 +2665,7 @@
<string name="marmot_legacy_group_no_disband">Den här gruppen skapades innan upplösning stöddes och kan inte upplösas. Du kan fortfarande lämna den eller ta bort alla andra medlemmar. Befintliga grupper kan inte uppgraderas.</string>
<string name="marmot_group_default_name">Marmot-grupp</string>
<string name="marmot_group_fallback_name">Grupp %1$s…</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="marmot_tab_known">Kända</string>
<string name="marmot_tab_known_count">Kända (%1$d)</string>
<string name="marmot_tab_new_requests">Nya förfrågningar</string>
@@ -2731,97 +2823,5 @@
<string name="buzz_persona_publishing">Publicerar…</string>
<string name="buzz_persona_publish">Publicera persona</string>
<string name="profile_card_follows_you">Följer dig</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="ai_tone_emojify">+ Emoji</string>
<string name="app_definition_kind_app">App</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_via">via %1$s</string>
<string name="banner_url">Banner URL</string>
<string name="buzz_canvas_body_label">Canvas (Markdown)</string>
<string name="buzz_canvas_title">Canvas</string>
<string name="buzz_invite_dismiss">OK</string>
<string name="buzz_system_unknown">%1$s: %2$s</string>
<string name="cashu_mint_label">Mint: %1$s</string>
<string name="cashu_mint_reachable_named">✓ %1$s</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_wizard_mints_label">Mints: %1$s</string>
<string name="classifieds_title_placeholder">iPhone 13</string>
<string name="clink_budget_set">Budget</string>
<string name="dm_sender_reported_more_count">+%1$d</string>
<string name="dvm_offline">Offline</string>
<string name="emoji_pack_count">%1$d emojis</string>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="gif">Gif</string>
<string name="git_commit">Commit</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_plain_text">Text</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_image_placeholder">https://example.com/image.jpg</string>
<string name="hls_codec_h264">H.264</string>
<string name="hls_codec_label">Codec</string>
<string name="language_preference_pair">%1$s → %2$s</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="marmot_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="marmot_user_fallback_name">%1$s…</string>
<string name="music_track_artist_label">Artist</string>
<string name="nest_live_chip">LIVE</string>
<string name="nest_role_moderator">Moderator</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_live">Live</string>
<string name="nip82_version_label">v%1$s</string>
<string name="not_available_acronym">N/A</string>
<string name="nutzap">Nutzap</string>
<string name="onchain_send_fee_rate_eta">%1$s sat/vB · %2$s</string>
<string name="onchain_send_sats_amount">%1$s sats</string>
<string name="onchain_send_sats_suffix">sats</string>
<string name="original">original</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
<string name="platform_web">Web</string>
<string name="podcast_explicit">Explicit</string>
<string name="podcast_trailer">Trailer</string>
<string name="podcast_value_node_pubkey_hint">02abc… (33-byte hex)</string>
<string name="podcast_value_stream_rate">%1$d sats/min</string>
<string name="podcast_video">Video</string>
<string name="post_not_found_short">👀</string>
<string name="profile_card_bot">Bot</string>
<string name="reactions_settings_zap">Zap</string>
<string name="relay_group_badge_live">LIVE</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_role_moderator">Moderator</string>
<string name="reload_mint_sats_amount">%1$s sats</string>
<string name="secret_visible_text_placeholder">😎</string>
<string name="security_unlimited"></string>
<string name="send_payment_method_cashu">Cashu</string>
<string name="send_payment_method_lightning">Lightning</string>
<string name="send_payment_method_onchain">On-chain</string>
<string name="share_of">%1$d/%2$d</string>
<string name="version">Version</string>
<string name="version_name">Version %1$s</string>
<string name="video_quality_auto">Auto</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_filter_zaps">Zaps</string>
<string name="wallet_sats">sats</string>
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
<string name="web_bookmark_url_label">URL</string>
<string name="workout_suggestion_distance_km">%1$s km</string>
<string name="health_connect_rationale_headline">Amethyst läser avslutade träningspass för att kunna förifylla ett träningsinlägg åt dig.</string>
<string name="health_connect_rationale_title">Health Connect och Amethyst</string>
<string name="health_connect_rationale_intro">Amethyst är en social Nostr-klient. I avsnittet Träningspass kan du publicera en sammanfattning av ett avslutat träningspass till de Nostr-reläer du väljer, så att de som följer dig kan se vad du har gjort. I stället för att skriva in varje siffra för hand kan Amethyst läsa det träningspass som din klocka eller träningsapp redan har sparat i Health Connect och förifylla inlägget. Du ser alltid det förifyllda inlägget och avgör själv om det ska publiceras.</string>
<string name="health_connect_rationale_what_title">Vad Amethyst läser och varför</string>
<string name="health_connect_rationale_exercise">Övning · vilken aktivitet du gjorde, när den började och hur länge den varade — det är själva träningspasset och även inläggets titel och varaktighet.</string>
<string name="health_connect_rationale_steps">Steg · antalet steg under en löprunda, promenad eller vandring.</string>
<string name="health_connect_rationale_distance">Distans · hur långt du tog dig, som visas som distansen för löprundan, cykelturen, promenaden eller simningen.</string>
<string name="health_connect_rationale_calories">Aktiva och totala kalorier · energin som träningspasset förbrände. Aktiva kalorier används när din källa registrerar dem; totala kalorier är reserven för källor som bara registrerar total energi.</string>
<string name="health_connect_rationale_elevation">Höjdmeter · hur mycket du klättrade, vilket skiljer en platt tur från en kuperad.</string>
<string name="health_connect_rationale_heart_rate">Puls · genomsnittlig och maximal puls under träningspasset, det vanliga måttet på hur ansträngande det var.</string>
<string name="health_connect_rationale_limits_title">Vad Amethyst inte gör</string>
<string name="health_connect_rationale_limit_window">Läser bara träningspass som avslutades de senaste 7 dagarna, och bara medan träningsredigeraren är öppen. Den läser aldrig i bakgrunden.</string>
<string name="health_connect_rationale_limit_publish">Ingenting lämnar din telefon förrän du trycker på ett förslag och publicerar inlägget själv. Amethyst har ingen server: inlägget går till de Nostr-reläer du har ställt in.</string>
<string name="health_connect_rationale_limit_write">Skriver aldrig något till Health Connect och begär aldrig din träningsrutt, plats eller någon annan typ av hälsodata.</string>
<string name="health_connect_rationale_limit_optional">Hela funktionen är valfri. Stäng av den under Inställningar → Skrivinställningar, eller återkalla behörigheterna i Health Connect när som helst — resten av Amethyst fortsätter att fungera.</string>
<string name="health_connect_rationale_privacy_policy">Läs hela integritetspolicyn</string>
<string name="workout_suggestion_connect_details">Vad Amethyst läser</string>
</resources>
+18 -9
View File
@@ -80,7 +80,24 @@
"translators": [
{
"user": "vitorpamplona",
"languages": []
"languages": [
"Czech",
"German",
"Portuguese, Brazilian",
"Swedish"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "greenart7c3",
@@ -250,10 +267,6 @@
"user": "D4rkFIow",
"languages": []
},
{
"user": "summoner001",
"languages": []
},
{
"user": "fiddleway",
"languages": []
@@ -274,10 +287,6 @@
"user": "hypnotichemionus4",
"languages": []
},
{
"user": "maxblake2015",
"languages": []
},
{
"user": "eiie7",
"languages": []
@@ -119,6 +119,10 @@ class RelayPool(
relays.forEach { url, relay ->
relay.disconnect()
}
// We just tore every socket down; say so here rather than leaving it to each socket's
// own report. The transports do report a disconnect synchronously (see [WebSocket]),
// but this flow is what the app reads as "connected", and it must not depend on that.
_connectedRelays.update { emptySet() }
}
fun sendOrConnectAndSync(
@@ -210,6 +214,9 @@ class RelayPool(
val relayInPool = relays.remove(relay)
if (relayInPool != null) {
relayInPool.disconnect()
// A relay that is no longer a member cannot be connected, whatever its socket
// layer reports (or fails to report) later.
_connectedRelays.update { it - relay }
return true
}
return false
@@ -226,6 +233,7 @@ class RelayPool(
disconnect()
relays.clear()
_availableRelays.update { emptySet() }
_connectedRelays.update { emptySet() }
}
}
@@ -20,11 +20,26 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.sockets
/**
* One socket session towards a relay, as the relay client sees it.
*
* The contract every implementation keeps, and that [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient]
* relies on for its bookkeeping:
*
* - A session ends with **exactly one** terminal callback on its [WebSocketListener], `onClosed`
* or `onFailure`, however it ends.
* - [disconnect] ends the session itself: it reports `onClosed` **synchronously**, before
* returning, and nothing from that socket reaches the listener afterwards. The relay client
* may dial a new socket immediately, so a late report from the old one -- which OkHttp
* delivers on its own threads for a cancel, and never delivers at all for a relay-initiated
* close it was not allowed to finish -- must be swallowed by the adapter, not forwarded.
*/
interface WebSocket {
fun needsReconnect(): Boolean
fun connect()
/** Ends the session now. Reports `onClosed` synchronously if one was open; a no-op otherwise. */
fun disconnect()
fun send(msg: String): Boolean
@@ -0,0 +1,81 @@
/*
* 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.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* [RelayPool.connectedRelays] is what the app reads as "how many relays am I connected to", so it
* must drop a relay the moment the pool lets go of it, even when the socket layer never confirms
* the close.
*
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebSocket.disconnect] is a
* no-op that never calls back, which is exactly what OkHttp did after a relay sent a CLOSE frame
* the app never answered: `cancel()` then fired neither `onClosed` nor `onFailure`, and the flow
* carried such relays for minutes. The real transports now report a disconnect synchronously (see
* [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket]), but this flow must not depend on
* that: the pool clears it itself on removal and on disconnect.
*/
class RelayPoolConnectedFlowTest {
private val url = NormalizedRelayUrl("wss://relay.example.com/")
private fun openedPool(): RelayPool {
val sockets = FakeWebsocketBuilder()
val pool = RelayPool(sockets)
pool.getOrCreateRelay(url).connect()
sockets.lastListener.onOpen(pingMillis = 10, compression = false)
return pool
}
@Test
fun anOpenSocketIsConnected() {
val pool = openedPool()
assertEquals(setOf(url), pool.connectedRelays.value)
assertEquals(1, pool.connectedRelaysCount())
}
@Test
fun removingARelayDropsItEvenWhenTheCloseIsSilent() {
val pool = openedPool()
// No subscription wants it anymore: the pool drops it and cancels its socket, and the
// socket layer stays silent.
pool.removeRelay(url)
assertEquals(emptySet(), pool.connectedRelays.value)
assertEquals(0, pool.connectedRelaysCount())
}
@Test
fun disconnectingThePoolDropsEveryRelayEvenWhenTheCloseIsSilent() {
val pool = openedPool()
// The host is putting the client down (app backgrounded, connectivity lost).
pool.disconnect()
assertEquals(emptySet(), pool.connectedRelays.value)
assertEquals(setOf(url), pool.availableRelays.value, "still a member, just not connected")
}
}
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.atomic.AtomicBoolean
import okhttp3.WebSocket as OkHttpWebSocket
import okhttp3.WebSocketListener as OkHttpWebSocketListener
@@ -51,11 +52,27 @@ class BasicOkHttpWebSocket(
}
}
private var socket: OkHttpWebSocket? = null
@Volatile private var socket: OkHttpWebSocket? = null
/**
* Set once, by whichever of `onClosed`, `onFailure` or [disconnect] ends the session first.
*
* One adapter is one session: the relay client builds a fresh one per dial, and OkHttp binds
* exactly one socket to the listener created in [connect], so anything that reaches that
* listener is from this session by construction. The only question a callback has to ask is
* whether the session already ended -- which is what keeps the [WebSocket.disconnect] contract:
* after [disconnect] the failure OkHttp raises for its own `cancel()` on the reader thread, or
* the `onClosed` its writer thread delivers once a close handshake completes, is dropped rather
* than reaching a relay client that has already moved on. Claimed with a compare-and-set so a
* [disconnect] racing a terminal callback still yields exactly one report.
*/
private val ended = AtomicBoolean(false)
override fun needsReconnect() = socket == null
override fun connect() {
if (socket != null || ended.get()) return
val request = Request.Builder().url(url.url).build()
val listener =
@@ -79,33 +96,65 @@ class BasicOkHttpWebSocket(
}
}
/** Claims the session's single terminal report. False if it already ended. */
private fun endSession(): Boolean {
if (!ended.compareAndSet(false, true)) return false
socket = null
incomingMessages.close()
job.cancel()
scope.cancel()
return true
}
override fun onOpen(
webSocket: OkHttpWebSocket,
response: Response,
) = out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
) {
if (ended.get()) return
out.onOpen(
(response.receivedResponseAtMillis - response.sentRequestAtMillis).toInt(),
response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false,
)
}
override fun onMessage(
webSocket: OkHttpWebSocket,
text: String,
) {
if (ended.get()) return
// Never blocks (unlimited channel): the OkHttp reader
// thread must stay free to keep draining the socket.
incomingMessages.trySendBlocking(text)
}
override fun onClosing(
webSocket: OkHttpWebSocket,
code: Int,
reason: String,
) {
// The relay sent a CLOSE frame. OkHttp's contract (WebSocketListener KDoc,
// RealWebSocket, and its own WebSocketEcho recipe) is that onClosed fires
// only once BOTH peers have sent a close, and sending ours is the
// application's job. Left unanswered, the socket sits half-closed: no
// onClosed, no onFailure, send() still accepted and silently discarded, and
// a later cancel() is silent too -- so the relay client kept believing it
// was connected, with its REQs live, until OkHttp's 120s ping path finally
// failed up to two intervals later. Answering completes the handshake and
// OkHttp reports onClosed at once, whether or not the relay still holds the
// TCP session open.
//
// Always 1000 rather than echoing `code`: close() validates the code it is
// asked to write and throws on the reserved ones (1005, 1006, 1015), and a
// relay may send anything.
webSocket.close(1000, null)
}
override fun onClosed(
webSocket: OkHttpWebSocket,
code: Int,
reason: String,
) {
// Close the channel when the WebSocket connection is closed.
incomingMessages.close()
job.cancel()
scope.cancel()
if (!endSession()) return
out.onClosed(code, reason)
}
@@ -114,11 +163,7 @@ class BasicOkHttpWebSocket(
t: Throwable,
response: Response?,
) {
// Close the channel on failure, and propagate the error.
incomingMessages.close()
job.cancel()
scope.cancel()
if (!endSession()) return
out.onFailure(t, response?.code, response?.message)
}
}
@@ -127,8 +172,14 @@ class BasicOkHttpWebSocket(
}
override fun disconnect() {
socket?.cancel()
// Claim the session ourselves: OkHttp's cancel() raises no callback when no reader is
// left to fail (the state a relay-initiated close leaves behind), and when it does the
// failure arrives later on its own thread. The relay client needs the answer now.
val closing = socket ?: return
if (!ended.compareAndSet(false, true)) return
socket = null
closing.cancel()
out.onClosed(1000, "client disconnect")
}
override fun send(msg: String): Boolean = socket?.send(msg) ?: false
@@ -140,6 +191,6 @@ class BasicOkHttpWebSocket(
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = BasicOkHttpWebSocket(url, httpClient, out)
): WebSocket = BasicOkHttpWebSocket(url, httpClient, out)
}
}
@@ -0,0 +1,249 @@
/*
* 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.relay.sockets.okhttp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.Base64
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
/**
* A relay-initiated close must be answered, or OkHttp never finishes the handshake.
*
* OkHttp fires `onClosed` only once BOTH peers have sent a CLOSE frame, and sending ours is the
* application's job (its `WebSocketEcho` recipe answers `onClosing` with `close(1000, null)`).
* Before [BasicOkHttpWebSocket] did that, a relay's CLOSE frame left the socket half-closed: no
* `onClosed`, no `onFailure`, `send()` still accepted and discarded, and a later `cancel()` silent
* too. The relay client kept believing it was connected, with its REQs live, until the ping path
* failed up to two ping intervals later.
*
* Driven against a minimal RFC 6455 server on a loopback [ServerSocket] rather than a mock
* server library, so the test needs no new dependency and controls the exact frames on the wire.
*/
class BasicOkHttpWebSocketCloseHandshakeTest {
/** Handshakes one client, sends it a CLOSE frame on demand, and records the frames it sends back. */
private class TinyRelay : AutoCloseable {
private val server = ServerSocket(0)
val url = NormalizedRelayUrl("ws://127.0.0.1:${server.localPort}/")
private val handshaken = CountDownLatch(1)
val clientCloseFrame = CountDownLatch(1)
val clientCloseCode = AtomicInteger(-1)
private var socket: Socket? = null
private var out: OutputStream? = null
private val thread =
thread(isDaemon = true, name = "tiny-relay") {
runCatching {
val s = server.accept()
socket = s
val input = s.getInputStream()
val output = s.getOutputStream()
out = output
handshake(input, output)
handshaken.countDown()
readFrames(input)
}
}
private fun handshake(
input: InputStream,
output: OutputStream,
) {
var key: String? = null
val line = StringBuilder()
while (true) {
val c = input.read()
check(c != -1) { "EOF during handshake" }
if (c == '\n'.code) {
val l = line.toString().trim()
if (l.isEmpty()) break
if (l.lowercase().startsWith("sec-websocket-key:")) key = l.substring(18).trim()
line.setLength(0)
} else if (c != '\r'.code) {
line.append(c.toChar())
}
}
val accept =
Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-1").digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray()),
)
output.write(
(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: $accept\r\n\r\n"
).toByteArray(Charsets.ISO_8859_1),
)
output.flush()
}
/** Client frames are masked; decode enough to spot a CLOSE and read its status code. */
private fun readFrames(input: InputStream) {
while (true) {
val b0 = input.read()
if (b0 == -1) return
val b1 = input.read()
if (b1 == -1) return
val opcode = b0 and 0x0F
var len = b1 and 0x7F
if (len == 126) {
len = (input.read() shl 8) or input.read()
} else if (len == 127) {
len = 0
repeat(8) { len = (len shl 8) or input.read() }
}
val masked = (b1 and 0x80) != 0
val mask = if (masked) ByteArray(4) { input.read().toByte() } else ByteArray(4)
val payload = ByteArray(len) { i -> (input.read() xor mask[i % 4].toInt()).toByte() }
if (opcode == 0x8) {
if (len >= 2) {
clientCloseCode.set(((payload[0].toInt() and 0xFF) shl 8) or (payload[1].toInt() and 0xFF))
}
clientCloseFrame.countDown()
}
}
}
fun awaitClient() = handshaken.await(5, TimeUnit.SECONDS)
/** Server-initiated CLOSE, status 1000, unmasked as servers send it. The TCP session stays open. */
fun sendClose() {
val output = checkNotNull(out) { "no client yet" }
output.write(byteArrayOf(0x88.toByte(), 0x02, 0x03, 0xE8.toByte()))
output.flush()
}
override fun close() {
runCatching { socket?.close() }
runCatching { server.close() }
thread.join(2_000)
}
}
private class Recorder : WebSocketListener {
val opened = CountDownLatch(1)
val closed = CountDownLatch(1)
val closedCount = AtomicInteger(0)
val closedCode = AtomicInteger(-1)
val failure = AtomicReference<Throwable?>(null)
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) = opened.countDown()
override suspend fun onMessage(text: String) {}
override fun onClosed(
code: Int,
reason: String,
) {
closedCode.set(code)
closedCount.incrementAndGet()
closed.countDown()
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
failure.set(t)
}
}
@Test
fun `a relay initiated close is answered and reported as closed`() {
TinyRelay().use { relay ->
val recorder = Recorder()
val client = OkHttpClient()
val socket = BasicOkHttpWebSocket(relay.url, { client }, recorder)
socket.connect()
assertTrue("relay never saw the client", relay.awaitClient())
assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS))
relay.sendClose()
// The half of the handshake that is ours to send.
assertTrue("client never answered the relay's CLOSE frame", relay.clientCloseFrame.await(5, TimeUnit.SECONDS))
assertEquals(1000, relay.clientCloseCode.get())
// And the terminal callback the relay client's bookkeeping depends on.
assertTrue("onClosed never fired", recorder.closed.await(5, TimeUnit.SECONDS))
assertEquals("the relay's status code is what gets reported", 1000, recorder.closedCode.get())
assertNull("a clean handshake is not a failure", recorder.failure.get())
// The session already ended; the usual teardown afterwards must not report it twice.
socket.disconnect()
assertEquals("one terminal report per session", 1, recorder.closedCount.get())
assertTrue("a closed socket needs a fresh dial", socket.needsReconnect())
client.dispatcher.executorService.shutdown()
}
}
@Test
fun `disconnect reports the session end once, synchronously, and drops what OkHttp says afterwards`() {
TinyRelay().use { relay ->
val recorder = Recorder()
val client = OkHttpClient()
val socket = BasicOkHttpWebSocket(relay.url, { client }, recorder)
socket.connect()
assertTrue("relay never saw the client", relay.awaitClient())
assertTrue("no onOpen", recorder.opened.await(5, TimeUnit.SECONDS))
socket.disconnect()
// Reported before disconnect() returned: the relay client dials the replacement
// right after this call and must not hear from the old socket later.
assertEquals("disconnect() must report synchronously", 1, recorder.closedCount.get())
assertEquals(1000, recorder.closedCode.get())
assertTrue(socket.needsReconnect())
// OkHttp's own reaction to cancel() -- a failure on its reader thread -- and the relay's
// reaction to the dropped TCP session must both be swallowed.
Thread.sleep(500)
assertEquals("no second report", 1, recorder.closedCount.get())
assertNull("the cancel's failure must not surface", recorder.failure.get())
client.dispatcher.executorService.shutdown()
}
}
}
-1
View File
@@ -25,7 +25,6 @@ dependencyResolutionManagement {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://raw.githubusercontent.com/guardianproject/gpmaven/master") }
}
}