mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge branch 'main' into claude/notifications-pagination-n0qahe
Bring the notifications-pagination feature up to date with main.
Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).
Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.
Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# v1.13.0 release QA — coverage, open findings, and recipes
|
||||
|
||||
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
|
||||
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
|
||||
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
|
||||
|
||||
This document records what that session **could not** capture in commit messages: what was actually
|
||||
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
|
||||
|
||||
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
|
||||
Everything below is that one configuration unless stated.
|
||||
|
||||
---
|
||||
|
||||
## 1. Coverage
|
||||
|
||||
### Exercised on device
|
||||
|
||||
| Area | Notes |
|
||||
|---|---|
|
||||
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
|
||||
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
|
||||
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
|
||||
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
|
||||
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
|
||||
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
|
||||
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
|
||||
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
|
||||
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
|
||||
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
|
||||
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
|
||||
|
||||
### Fixed but **only unit-verified** — never run on a device
|
||||
|
||||
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
|
||||
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
|
||||
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
|
||||
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
|
||||
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
|
||||
podcast duplicate description · Concord leave affordance · the three revocation wirings.
|
||||
|
||||
### Never opened at all
|
||||
|
||||
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
|
||||
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
|
||||
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
|
||||
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
|
||||
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
|
||||
|
||||
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
|
||||
pinned messages were never exercised.
|
||||
|
||||
### Platform gaps
|
||||
|
||||
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
|
||||
An emulator makes this cheap and it is the highest-value remaining gap.
|
||||
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
|
||||
`release` (full R8) has never been built or run. **arm64 only.**
|
||||
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
|
||||
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open findings (known, deliberately not fixed)
|
||||
|
||||
**Release mechanics**
|
||||
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
|
||||
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
|
||||
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
|
||||
ships blind.
|
||||
|
||||
**Correctness / UX**
|
||||
- Tor settings do not take effect until app restart, with no indication.
|
||||
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
|
||||
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
|
||||
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
|
||||
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
|
||||
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
|
||||
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
|
||||
- `amy login bunker://…` hangs and never delivers a `connect`.
|
||||
|
||||
**Security / protocol**
|
||||
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
|
||||
revokes nothing.
|
||||
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
|
||||
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
|
||||
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
|
||||
keys; only the community list was fixed.
|
||||
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
|
||||
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
|
||||
spec conversation, not a unilateral fix.
|
||||
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
|
||||
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
|
||||
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
|
||||
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
|
||||
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
|
||||
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
|
||||
the rank-aware `canActOnPosition`.
|
||||
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
|
||||
wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must
|
||||
hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin
|
||||
cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the
|
||||
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
|
||||
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
|
||||
**FIXED and shipping** — `AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
|
||||
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
|
||||
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
|
||||
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
|
||||
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
|
||||
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
|
||||
**Known consequence: Armada has not shipped this, so banlists can differ between clients** —
|
||||
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
|
||||
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
|
||||
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
|
||||
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
|
||||
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
|
||||
that does not chain onto it.
|
||||
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
|
||||
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
|
||||
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Setup recipes
|
||||
|
||||
**`amy` with an isolated identity** (never touch the maintainer's real one):
|
||||
|
||||
```
|
||||
AMY=$(pwd)/cli/build/install/amy/bin/amy
|
||||
H=/tmp/qa-home; mkdir -p $H
|
||||
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
|
||||
```
|
||||
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
|
||||
`--secret-backend plaintext` for throwaway identities.
|
||||
|
||||
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
|
||||
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
|
||||
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
|
||||
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
|
||||
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
|
||||
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
|
||||
`put-user … --role admin`.
|
||||
|
||||
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
|
||||
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
|
||||
and after the user action — that turns "I didn't see traffic" into an actual measurement.
|
||||
|
||||
---
|
||||
|
||||
## 4. Patterns worth acting on
|
||||
|
||||
These recurred often enough to be process problems rather than individual bugs.
|
||||
|
||||
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
|
||||
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
|
||||
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
|
||||
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
|
||||
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
|
||||
|
||||
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
|
||||
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
|
||||
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
|
||||
while being unreachable to users, and the first one actually invoked turned out to be **broken as
|
||||
written**. A lint for "public capability with no caller outside its declaring file" would catch the
|
||||
whole class cheaply.
|
||||
|
||||
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
|
||||
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
|
||||
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
|
||||
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
|
||||
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
|
||||
already bounds these queries, which is what makes dropping the time floor safe.
|
||||
|
||||
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
|
||||
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
|
||||
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
|
||||
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
|
||||
with others`), not membership modelling. Instrument first.
|
||||
|
||||
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
|
||||
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
|
||||
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
|
||||
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
|
||||
|
||||
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
|
||||
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
|
||||
Removing it would have been a confident, review-passing regression.
|
||||
|
||||
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
|
||||
committed another worker's temporary revert. Stage explicit paths, always.
|
||||
@@ -32,6 +32,9 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.LogLevel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -95,6 +98,10 @@ class Amethyst : Application() {
|
||||
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
|
||||
BrowserIconRegistry.init(this)
|
||||
|
||||
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
|
||||
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
|
||||
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
|
||||
|
||||
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
|
||||
// exits) starts on the open web without first flashing a failed Tor load.
|
||||
WebAppNetworkRegistry.init(this)
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
|
||||
@@ -694,8 +695,32 @@ class AppModules(
|
||||
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
|
||||
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
|
||||
|
||||
/**
|
||||
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
|
||||
* call (never captured) so an account switch immediately moves embedded apps to the new account's
|
||||
* namespace: an app authorized by one npub is never authorized under another.
|
||||
*/
|
||||
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
|
||||
|
||||
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
|
||||
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
|
||||
|
||||
/**
|
||||
* The one napplet permission ledger for the main process. Its persistent half is just the store
|
||||
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
|
||||
* caller shares this instance. The broker service and the Connected Apps screens used to build
|
||||
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
|
||||
* session map while the grants the broker was actually consulting lived on untouched.
|
||||
*
|
||||
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
|
||||
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
|
||||
*/
|
||||
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
|
||||
|
||||
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
|
||||
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
|
||||
// account rather than the active one. The napplet path namespaces its own coordinate the same way
|
||||
// (see NappletBroker.signerCoordinateFor) instead.
|
||||
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
|
||||
|
||||
// Display + relay info for connected NIP-46 remote-signer clients.
|
||||
|
||||
@@ -236,6 +236,19 @@ object LocalPreferences {
|
||||
// the source of truth, so there is no async hydrate that could clobber a user toggle.
|
||||
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
|
||||
* thread, so the first synchronous read below hits memory rather than disk.
|
||||
*
|
||||
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
|
||||
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
|
||||
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
|
||||
* simply pays the disk hit once, exactly as before.
|
||||
*/
|
||||
fun warmGlobalSettings() {
|
||||
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
|
||||
}
|
||||
|
||||
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
|
||||
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
|
||||
}
|
||||
|
||||
+35
-6
@@ -193,6 +193,16 @@ private fun SignerConsentDialog(
|
||||
if (info.accountName != null) {
|
||||
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
|
||||
}
|
||||
// For a decrypt request, WHOSE conversation is being read is the decision. Show
|
||||
// that person as an avatar + name, never as nothing.
|
||||
if (info.counterpartyName != null) {
|
||||
Text(
|
||||
stringResource(R.string.nip46_signer_messages_with),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -204,12 +214,31 @@ private fun SignerConsentDialog(
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Primary: always allow this op
|
||||
Button(
|
||||
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_always))
|
||||
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
|
||||
// Alice" — one broad decrypt grant would otherwise hand over every conversation
|
||||
// forever, and scoping the op itself would mean a prompt per conversation.
|
||||
val narrowOp = info.narrowOp
|
||||
if (narrowOp != null && info.narrowOpLabel != null) {
|
||||
Button(
|
||||
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(info.narrowOpLabel)
|
||||
}
|
||||
// The broad grant stays available, but demoted below the scoped one.
|
||||
OutlinedButton(
|
||||
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_always))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.napplet_consent_allow_always))
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: allow just once
|
||||
|
||||
+17
@@ -65,6 +65,23 @@ data class SignerConsentInfo(
|
||||
* non-event ops.
|
||||
*/
|
||||
val previewTemplate: EventTemplate<Event>? = null,
|
||||
/**
|
||||
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
|
||||
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
|
||||
* decision from "X wants to read your private messages", so this must reach the dialog.
|
||||
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
|
||||
*/
|
||||
val counterpartyName: String? = null,
|
||||
val counterpartyPicture: String? = null,
|
||||
val counterpartyPubKey: String? = null,
|
||||
/**
|
||||
* A NARROWER op the dialog may offer to remember instead of [op] — today only
|
||||
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
|
||||
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
|
||||
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
|
||||
*/
|
||||
val narrowOp: NostrSignerOp? = null,
|
||||
val narrowOpLabel: String? = null,
|
||||
)
|
||||
|
||||
/** One pending per-operation consent request, as the batched sheet renders it. */
|
||||
|
||||
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
|
||||
|
||||
@Volatile private var iconDir: File? = null
|
||||
|
||||
/** Binds the app context and indexes already-stored icons. Idempotent. */
|
||||
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
|
||||
// that must not block: init() from app startup and record() from the broker's IPC handler, which
|
||||
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
|
||||
// UI while a favicon was saved.
|
||||
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Binds the app context and indexes already-stored icons. Idempotent.
|
||||
*
|
||||
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
|
||||
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
|
||||
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
|
||||
* drives recomposition.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (iconDir != null) return
|
||||
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
|
||||
val dir = File(context.applicationContext.filesDir, DIR)
|
||||
iconDir = dir
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
io.launch {
|
||||
dir.mkdirs()
|
||||
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
|
||||
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
|
||||
val dir = iconDir ?: return
|
||||
if (host.isBlank() || bytes.isEmpty()) return
|
||||
val key = sanitize(host)
|
||||
try {
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
|
||||
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
|
||||
// icon exists before the file backing it does.
|
||||
io.launch {
|
||||
try {
|
||||
dir.mkdirs()
|
||||
File(dir, key + PNG).writeBytes(bytes)
|
||||
_keys.update { it + key }
|
||||
} catch (e: Exception) {
|
||||
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.napplet.NappletLauncher
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
|
||||
@@ -92,9 +93,20 @@ object FavoriteAppLauncher {
|
||||
}
|
||||
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
|
||||
val intent =
|
||||
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
|
||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
NappletBrowserActivity
|
||||
.intent(
|
||||
context,
|
||||
url,
|
||||
proxyPort,
|
||||
useTor,
|
||||
theme = theme,
|
||||
isFavorite = isFavorite,
|
||||
// Opaque per-account storage partition, so a web app can't carry one npub's session
|
||||
// into another. Derived here (the sandbox never sees the pubkey).
|
||||
webViewProfile = NappletWebViewProfiles.current(),
|
||||
).apply {
|
||||
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
|
||||
@@ -363,6 +364,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
|
||||
@@ -372,6 +374,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
|
||||
/**
|
||||
* How often a joined Concord community's stored invite link is re-resolved to check whether
|
||||
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
|
||||
* rare and silent, so this trades detection latency for not turning the revision tick into a
|
||||
* relay-fetch loop.
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
@@ -2096,6 +2106,16 @@ class Account(
|
||||
* bundle we can't open (e.g. minted by a newer client) must not strand the user
|
||||
* on a spinner that retries forever.
|
||||
*
|
||||
* A bundle whose `expires_at` has passed is rejected with
|
||||
* [ConcordInviteResult.Expired]. Expiry is resolved inside
|
||||
* [ConcordActions.classifyInvite], so it is enforced on every redeem path rather
|
||||
* than being a field nobody reads.
|
||||
*
|
||||
* **This must only ever be called from an explicit user action.** It contacts
|
||||
* relay URLs carried in the link (chosen by whoever minted it) and publishes a
|
||||
* Guestbook JOIN signed by this account, so calling it on deep-link arrival would
|
||||
* leak the user's IP and enroll them without consent — see `ConcordInviteScreen`.
|
||||
*
|
||||
* If the resolved community is already in the joined list, this returns
|
||||
* [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook
|
||||
* JOIN, so reopening an old invite for a community you're already in simply takes
|
||||
@@ -2118,6 +2138,7 @@ class Account(
|
||||
val bundle =
|
||||
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
|
||||
is InviteBundleStatus.Live -> status.invite
|
||||
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
|
||||
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
|
||||
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
|
||||
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
|
||||
@@ -2141,6 +2162,10 @@ class Account(
|
||||
relays = bundle.relays,
|
||||
name = bundle.name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
// Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a
|
||||
// Refounding that leaves us out of the recipient set is recoverable later. See
|
||||
// recoverStrandedConcordCommunities().
|
||||
inviteRef = ConcordActions.bareInviteRef(url),
|
||||
)
|
||||
joinConcordCommunity(entry)
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
@@ -2349,7 +2374,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2411,12 +2436,12 @@ class Account(
|
||||
val roleIdHex =
|
||||
existing?.key ?: run {
|
||||
val roleId = RandomInstance.bytes(32)
|
||||
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
|
||||
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, roleWrap)
|
||||
roleId.toHexKey()
|
||||
}
|
||||
|
||||
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now())
|
||||
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -2428,17 +2453,26 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now())
|
||||
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* If [note] is a Concord channel message whose author this account is allowed to
|
||||
* ban — the actor is the owner or holds the BAN permission, and the target is
|
||||
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
|
||||
* otherwise, so the UI shows the Ban action only when it would actually take
|
||||
* effect on fold.
|
||||
* ban — the actor outranks the target and holds the BAN permission, and the target
|
||||
* is neither the owner nor the actor — returns `(communityId, memberHex)`. Null
|
||||
* otherwise, so the UI offers Ban only where we are willing to act.
|
||||
*
|
||||
* The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the
|
||||
* BANLIST is a single whole-list entity, so neither this client's fold nor Armada's
|
||||
* rank-checks the *contents* of a banlist edition — both gate only on the author's
|
||||
* BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its
|
||||
* role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin
|
||||
* above them is therefore *accepted* by every client today. Since we cannot refuse
|
||||
* such a ban without diverging from Armada, we at least refuse to author one — this
|
||||
* restricts what we write, never what we accept, so it cannot split consensus.
|
||||
* Enforcing it on the fold needs a spec change; see the QA plan's open findings.
|
||||
*/
|
||||
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
|
||||
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
|
||||
@@ -2452,7 +2486,11 @@ class Account(
|
||||
?.value
|
||||
?.authority ?: return null
|
||||
if (authority.isOwner(author)) return null
|
||||
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// The owner short-circuits rather than going through canActOn: canActOn starts at
|
||||
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
|
||||
// the owner on the banlist (see the KDoc) — routing the owner through it would let them be
|
||||
// locked out of moderating their own community.
|
||||
val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN)
|
||||
return if (canBan) communityId to author else null
|
||||
}
|
||||
|
||||
@@ -2463,7 +2501,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2475,7 +2513,7 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2489,7 +2527,7 @@ class Account(
|
||||
/**
|
||||
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
|
||||
* roll the `community_root`, re-key every retained member (Guestbook membership ∪
|
||||
* the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted
|
||||
* observed authors ∪ the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted
|
||||
* Control Plane under the new root. A removed member keeps the prior root (so
|
||||
* their history stays readable) but receives no blob, so they can never decrypt
|
||||
* anything published after the rotation.
|
||||
@@ -2514,14 +2552,23 @@ class Account(
|
||||
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
|
||||
// first, so each subsequent edition chains onto the updated banlist head.
|
||||
for (target in removedLower) {
|
||||
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now())
|
||||
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, banWrap)
|
||||
}
|
||||
|
||||
// 2. Recipient set: everyone we're keeping — Guestbook joins ∪ roster ∪ self, minus the
|
||||
// removed and the already-banned.
|
||||
// 2. Recipient set: everyone we're keeping, minus the removed and the already-banned.
|
||||
// Uses allMembers() — Guestbook joins ∪ OBSERVED AUTHORS ∪ roster ∪ owner — not just the
|
||||
// Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other
|
||||
// clients need not), so building the set without observed authors silently expelled every
|
||||
// member who had only ever posted: they hold no role, receive no blob, and the Refounding
|
||||
// strands them. That mainly hit cross-client communities, where Armada members are the
|
||||
// bulk of the roster.
|
||||
//
|
||||
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
|
||||
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
|
||||
// cannot re-key them. Stranded recovery is what gets those members back.
|
||||
val recipients =
|
||||
(session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
|
||||
(session.allMembers() + signer.pubKey)
|
||||
.mapTo(HashSet()) { it.lowercase() }
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
@@ -2589,6 +2636,13 @@ class Account(
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
|
||||
// out of would be unrecoverable.
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
|
||||
// must survive our rotation write, or we delete their data on every rekey.
|
||||
residue = entry.residue,
|
||||
)
|
||||
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
|
||||
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
|
||||
@@ -2597,10 +2651,23 @@ class Account(
|
||||
/**
|
||||
* Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for
|
||||
* each joined community, look for our new root among the kind-3303 wraps seen at
|
||||
* our next base-rekey address. If a role-authorized rotator (owner or a current
|
||||
* BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session
|
||||
* rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap
|
||||
* never re-triggers. Called on every Concord revision tick.
|
||||
* our next base-rekey address. If a role-authorized rotator (owner or a current,
|
||||
* non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the
|
||||
* session rebuilds at the new epoch and its next-rekey address moves on, so a stale
|
||||
* wrap never re-triggers. Called on every Concord revision tick.
|
||||
*
|
||||
* Authority is the roster, never key possession: any non-banned BAN-holder may
|
||||
* rotate, including for the owner. The owner deliberately does NOT refuse a root
|
||||
* authored by someone else — refusing would strand the owner alone on the dead
|
||||
* epoch whenever an admin legitimately rotates, and would diverge from Armada,
|
||||
* which forks a community across clients. Self-escalation to BAN is prevented
|
||||
* upstream by the role rank gate in AuthorityResolver.
|
||||
*
|
||||
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
|
||||
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
|
||||
* owner included) by omission — nothing on this receive path can prevent it. The
|
||||
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
|
||||
* the invite link the membership was joined through and merges forward.
|
||||
*/
|
||||
private suspend fun drainConcordRekeys() {
|
||||
if (!isWriteable()) return
|
||||
@@ -2618,12 +2685,76 @@ class Account(
|
||||
) ?: continue
|
||||
if (received.newEpoch <= entry.rootEpoch) continue
|
||||
val authority = session.state.value?.authority ?: continue
|
||||
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
|
||||
|
||||
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
||||
}
|
||||
}
|
||||
|
||||
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
|
||||
// Concord revision tick (which fires on every structural change) without turning it into a
|
||||
// relay-fetch loop.
|
||||
private val lastConcordRecoveryCheck = ConcurrentHashMap<String, Long>()
|
||||
|
||||
/**
|
||||
* Stranded recovery (CORD-05/06 receive path). A Refounding carries only
|
||||
* `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left
|
||||
* out of the rekey recipient set receives nothing and sits on the dead epoch
|
||||
* forever while everyone else moves on. This happens to any member, the owner
|
||||
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
|
||||
* miss detecting.
|
||||
*
|
||||
* The way back is the invite link the membership was joined through
|
||||
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
|
||||
* carried through every rotation by [adoptConcordRoot]). The community keeps
|
||||
* re-minting its bundle at that same addressable coordinate, so a bundle there at
|
||||
* a **strictly higher** epoch than ours proves we were left behind — and carries
|
||||
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
|
||||
* invites, legacy entries) are inert here; that is expected, not an error.
|
||||
*
|
||||
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
|
||||
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
|
||||
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
|
||||
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
|
||||
* ordinary rotation does, so the recovered member is visible to whoever refounds
|
||||
* next instead of being silently dropped again.
|
||||
*
|
||||
* Called on the Concord revision tick, but rate-limited per community
|
||||
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
|
||||
*/
|
||||
private suspend fun recoverStrandedConcordCommunities() {
|
||||
if (!isWriteable()) return
|
||||
val now = TimeUtils.nowMillis()
|
||||
for (entry in concordChannelList.liveCommunities.value) {
|
||||
val inviteRef = entry.inviteRef ?: continue
|
||||
val last = lastConcordRecoveryCheck[entry.id]
|
||||
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
||||
lastConcordRecoveryCheck[entry.id] = now
|
||||
|
||||
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
|
||||
val relays =
|
||||
(
|
||||
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
|
||||
entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
).toSet()
|
||||
if (relays.isEmpty()) continue
|
||||
|
||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||
val wraps = client.fetchAll(filters = filters)
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
|
||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the community metadata (name / icon / description / relays) with a new
|
||||
* Control-Plane edition. Honored on fold only when this account holds
|
||||
@@ -2640,7 +2771,7 @@ class Account(
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2658,7 +2789,7 @@ class Account(
|
||||
if (!isWriteable()) return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2671,8 +2802,16 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -2685,8 +2824,15 @@ class Account(
|
||||
): Boolean {
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!isWriteable()) return false
|
||||
val channel = ChannelEntity(name = name.trim(), deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
session.state.value
|
||||
?.channels
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -4922,7 +5068,10 @@ class Account(
|
||||
else -> event.content
|
||||
}
|
||||
} else {
|
||||
event.content
|
||||
// A read-only (npub-only) account holds no key, so nothing above can run. Returning
|
||||
// `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the
|
||||
// UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable".
|
||||
if (event.hasEncryptedContent()) null else event.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4949,6 +5098,11 @@ class Account(
|
||||
draftsDecryptionCache.cachedDraft(event)?.content
|
||||
}
|
||||
|
||||
// Encrypted kinds that reached here did so because this account is not writeable
|
||||
// (every branch above is gated on isWriteable). Their `content` is ciphertext —
|
||||
// hand back null rather than let the blob render. See cachedDecryptContent.
|
||||
event != null && event.hasEncryptedContent() -> null
|
||||
|
||||
else -> {
|
||||
event?.content
|
||||
}
|
||||
@@ -5379,6 +5533,9 @@ class Account(
|
||||
refreshConcordChannelIndex()
|
||||
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
|
||||
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
|
||||
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
|
||||
// found by re-resolving the invite link we joined through. Rate-limited internally.
|
||||
runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
|
||||
*/
|
||||
data object Revoked : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
|
||||
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
|
||||
* user's next step is to ask for a fresh one.
|
||||
*/
|
||||
data object Expired : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The bundle event was found but could not be opened with the link's token —
|
||||
* typically because it was minted by a newer/incompatible Concord client whose
|
||||
|
||||
+49
-31
@@ -29,14 +29,14 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.napplet.label
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
@@ -119,39 +119,43 @@ object Nip46ConsentBridge {
|
||||
} ?: AppConnectResult.Cancelled
|
||||
}
|
||||
|
||||
/** Per-operation consent: describe the request (op + event preview) and await the user's grant. */
|
||||
/**
|
||||
* Per-operation consent: describe the request and await the user's grant.
|
||||
*
|
||||
* For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the
|
||||
* counterparty the conversation is with. That is what makes the decision reviewable: without it
|
||||
* the dialog said only "wants to read your private messages" with no way to tell one request from
|
||||
* another. Decryption is local — [signer] runs on this device and nothing leaves it unless the
|
||||
* user approves — and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer
|
||||
* degrades to an explanatory message instead of hanging or blanking the prompt.
|
||||
*/
|
||||
suspend fun requestOp(
|
||||
coordinate: String,
|
||||
clientPubKey: HexKey,
|
||||
op: NostrSignerOp,
|
||||
request: BunkerRequest,
|
||||
signer: NostrSigner,
|
||||
): SignerOpGrant {
|
||||
val context = Amethyst.instance.appContext
|
||||
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
|
||||
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
|
||||
val preview =
|
||||
if (request is BunkerRequestSign) {
|
||||
request.event.content
|
||||
.take(160)
|
||||
.trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else ""
|
||||
val face = accountFace(coordinate)
|
||||
|
||||
val consentInfo =
|
||||
SignerConsentInfo(
|
||||
appletTitle = title,
|
||||
Nip46ConsentInfoBuilder.build(
|
||||
coordinate = coordinate,
|
||||
op = op,
|
||||
operationSummary = op.label(context),
|
||||
contentPreview = preview,
|
||||
rawData = rawData,
|
||||
title = title,
|
||||
iconUrl = info?.image,
|
||||
accountName = face.name,
|
||||
accountPicture = face.picture,
|
||||
accountPubKey = face.pubKey,
|
||||
previewTemplate = (request as? BunkerRequestSign)?.event,
|
||||
op = op,
|
||||
request = request,
|
||||
account = accountFace(coordinate),
|
||||
faceOf = ::userFace,
|
||||
strings =
|
||||
Nip46ConsentStrings(
|
||||
opLabel = { it.label(context) },
|
||||
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
|
||||
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
|
||||
),
|
||||
decrypt = { decryptWithAccountSigner(signer, it) },
|
||||
)
|
||||
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
|
||||
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
|
||||
@@ -159,16 +163,30 @@ object Nip46ConsentBridge {
|
||||
} ?: SignerOpGrant.DenyOnce
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the local decryption behind the decrypt preview with the account's own signer. Errors
|
||||
* and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call.
|
||||
*/
|
||||
private suspend fun decryptWithAccountSigner(
|
||||
signer: NostrSigner,
|
||||
request: BunkerRequest,
|
||||
): String? =
|
||||
when (request) {
|
||||
is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey)
|
||||
is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
|
||||
private fun accountFace(coordinate: String): AccountFace {
|
||||
private fun accountFace(coordinate: String): SignerFace {
|
||||
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
|
||||
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
|
||||
return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
|
||||
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
|
||||
}
|
||||
|
||||
private data class AccountFace(
|
||||
val name: String?,
|
||||
val picture: String?,
|
||||
val pubKey: String?,
|
||||
)
|
||||
/** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */
|
||||
private fun userFace(pubKey: HexKey): SignerFace {
|
||||
val user = LocalCache.getUserIfExists(pubKey)
|
||||
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.model.nip46Signer
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Avatar + display name for one pubkey, as the consent dialogs render it. */
|
||||
data class SignerFace(
|
||||
val name: String?,
|
||||
val picture: String?,
|
||||
val pubKey: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The user-visible strings the builder needs, injected rather than read from `R.string` so the
|
||||
* builder itself carries no Android dependency and can be unit-tested.
|
||||
*/
|
||||
class Nip46ConsentStrings(
|
||||
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
|
||||
val opLabel: (NostrSignerOp) -> String,
|
||||
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
|
||||
val allowAlwaysFor: (String) -> String,
|
||||
/** Shown as the preview when Amethyst itself could not decrypt the message. */
|
||||
val decryptFailed: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt.
|
||||
*
|
||||
* Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the
|
||||
* decisions that matter for safety are testable without an emulator:
|
||||
* - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the
|
||||
* contract the dialog documented but never implemented;
|
||||
* - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one;
|
||||
* - the counterparty label is never empty — it degrades to a shortened npub, never to nothing.
|
||||
*/
|
||||
object Nip46ConsentInfoBuilder {
|
||||
/** Characters of plaintext/content shown inline before the "show more" toggle takes over. */
|
||||
const val PREVIEW_MAX_CHARS = 160
|
||||
|
||||
/**
|
||||
* Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the
|
||||
* prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding)
|
||||
* must not delay the dialog.
|
||||
*/
|
||||
const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L
|
||||
|
||||
suspend fun build(
|
||||
coordinate: String,
|
||||
title: String,
|
||||
iconUrl: String?,
|
||||
op: NostrSignerOp,
|
||||
request: BunkerRequest,
|
||||
account: SignerFace,
|
||||
/** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */
|
||||
faceOf: (HexKey) -> SignerFace,
|
||||
strings: Nip46ConsentStrings,
|
||||
/** Performs the local decryption. May fail, return null, or hang — all are handled. */
|
||||
decrypt: suspend (BunkerRequest) -> String?,
|
||||
): SignerConsentInfo {
|
||||
val counterparty = request.decryptCounterparty()
|
||||
val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null
|
||||
|
||||
val preview =
|
||||
when {
|
||||
request is BunkerRequestSign ->
|
||||
request.event.content
|
||||
.take(PREVIEW_MAX_CHARS)
|
||||
.trim()
|
||||
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
|
||||
else -> ""
|
||||
}
|
||||
val rawData =
|
||||
when {
|
||||
request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event)
|
||||
// Only worth a "show more" toggle when the preview actually truncated it.
|
||||
plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to
|
||||
// the broad "always allow", instead of only the all-conversations-forever choice.
|
||||
val narrowOp = request.toNarrowSignerOp()
|
||||
val counterpartyFace = counterparty?.let { face(it, faceOf) }
|
||||
|
||||
return SignerConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = coordinate,
|
||||
op = op,
|
||||
// For decrypt this names the counterparty ("read your private messages with Alice").
|
||||
operationSummary = strings.opLabel(narrowOp ?: op),
|
||||
contentPreview = preview,
|
||||
rawData = rawData,
|
||||
iconUrl = iconUrl,
|
||||
accountName = account.name,
|
||||
accountPicture = account.picture,
|
||||
accountPubKey = account.pubKey,
|
||||
previewTemplate = (request as? BunkerRequestSign)?.event,
|
||||
counterpartyName = counterpartyFace?.name,
|
||||
counterpartyPicture = counterpartyFace?.picture,
|
||||
counterpartyPubKey = counterparty,
|
||||
narrowOp = narrowOp,
|
||||
narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
|
||||
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
|
||||
* ciphertext we cannot even read is itself worth showing — a blank dialog is not.
|
||||
*/
|
||||
private suspend fun decryptPreview(
|
||||
request: BunkerRequest,
|
||||
decrypt: suspend (BunkerRequest) -> String?,
|
||||
failureText: String,
|
||||
): String =
|
||||
withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) {
|
||||
try {
|
||||
decrypt(request)?.ifBlank { null }
|
||||
} catch (e: CancellationException) {
|
||||
// Includes this block's own timeout — must propagate so withTimeoutOrNull sees it.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
} ?: failureText
|
||||
|
||||
/** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */
|
||||
private fun face(
|
||||
pubKey: HexKey,
|
||||
faceOf: (HexKey) -> SignerFace,
|
||||
): SignerFace {
|
||||
val resolved = runCatching { faceOf(pubKey) }.getOrNull()
|
||||
return SignerFace(
|
||||
name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey),
|
||||
picture = resolved?.picture,
|
||||
pubKey = pubKey,
|
||||
)
|
||||
}
|
||||
|
||||
/** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */
|
||||
fun shortIdentifier(pubKey: HexKey): String {
|
||||
val npub = runCatching { NPub.create(pubKey) }.getOrNull()
|
||||
return if (!npub.isNullOrBlank()) npub.take(12) + "…" else pubKey.take(12) + "…"
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -170,7 +170,12 @@ class Nip46SignerState(
|
||||
// connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds,
|
||||
// decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing.
|
||||
connectConsent = Nip46ConsentBridge::requestConnect,
|
||||
opConsent = Nip46ConsentBridge::requestOp,
|
||||
// The account's own signer goes to the bridge so a decrypt request can be decrypted
|
||||
// BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque
|
||||
// "wants to read your private messages". Local only; nothing is disclosed until approval.
|
||||
opConsent = { coordinate, clientPubKey, op, request ->
|
||||
Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer)
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
|
||||
+20
-7
@@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
|
||||
*/
|
||||
class DataStoreNappletPermissionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletPermissionStore {
|
||||
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
|
||||
|
||||
/**
|
||||
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
|
||||
* every read and write to that account's namespace with no rebuild — a grant made by one account
|
||||
* can never authorize another.
|
||||
*/
|
||||
private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate"
|
||||
|
||||
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
|
||||
val prefs = dataStore.data.first()
|
||||
val prefix = "$coordinate$SEP"
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
val result = mutableMapOf<NappletCapability, GrantState>()
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
@@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore(
|
||||
}
|
||||
|
||||
override suspend fun clear(coordinate: String) {
|
||||
val prefix = "$coordinate$SEP"
|
||||
val prefix = "${scoped(coordinate)}$SEP"
|
||||
dataStore.edit { prefs ->
|
||||
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
|
||||
toRemove.forEach { prefs.remove(it) }
|
||||
@@ -78,11 +87,15 @@ class DataStoreNappletPermissionStore(
|
||||
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
|
||||
val prefs = dataStore.data.first()
|
||||
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
|
||||
val accountPrefix = "${accountPubKey()}$SEP"
|
||||
for ((key, value) in prefs.asMap()) {
|
||||
val name = key.name
|
||||
// Key is "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
|
||||
val capName = name.substringAfterLast(SEP, "")
|
||||
val coordinate = name.substringBeforeLast(SEP, "")
|
||||
// Key is "<account><SEP><coordinate><SEP><CAPABILITY>". Only the active account's grants
|
||||
// are listed, so the Connected Apps screen never surfaces another account's permissions.
|
||||
if (!name.startsWith(accountPrefix)) continue
|
||||
val scoped = name.removePrefix(accountPrefix)
|
||||
val capName = scoped.substringAfterLast(SEP, "")
|
||||
val coordinate = scoped.substringBeforeLast(SEP, "")
|
||||
if (capName.isEmpty() || coordinate.isEmpty()) continue
|
||||
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
|
||||
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
|
||||
@@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore(
|
||||
private fun keyOf(
|
||||
coordinate: String,
|
||||
capability: NappletCapability,
|
||||
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
|
||||
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
|
||||
|
||||
companion object {
|
||||
private const val SEP = "\u0000"
|
||||
|
||||
+17
-6
@@ -32,14 +32,20 @@ import kotlinx.coroutines.flow.first
|
||||
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
|
||||
|
||||
/**
|
||||
* DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one
|
||||
* napplet's keys can never collide with another's, and this store is entirely separate from the
|
||||
* app's own preferences.
|
||||
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
|
||||
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
|
||||
* never visible to another, and this store is entirely separate from the app's own preferences.
|
||||
*
|
||||
* [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and
|
||||
* writes to the new namespace with no rebuild — an embedded applet always sees the current account's
|
||||
* data and never the previous one's.
|
||||
*/
|
||||
class DataStoreNappletStorage(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val accountPubKey: () -> String,
|
||||
) : NappletStorage {
|
||||
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
|
||||
constructor(context: Context, accountPubKey: () -> String) :
|
||||
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
|
||||
|
||||
override suspend fun get(
|
||||
coordinate: String,
|
||||
@@ -62,7 +68,9 @@ class DataStoreNappletStorage(
|
||||
}
|
||||
|
||||
override suspend fun keys(coordinate: String): List<String> {
|
||||
val prefix = "$coordinate "
|
||||
// Must match keyOf's separator exactly. This filtered on a space while keys are written
|
||||
// with NUL, so no key could ever match and keys() always returned an empty list.
|
||||
val prefix = prefixOf(coordinate)
|
||||
return dataStore.data
|
||||
.first()
|
||||
.asMap()
|
||||
@@ -72,8 +80,11 @@ class DataStoreNappletStorage(
|
||||
.map { it.removePrefix(prefix) }
|
||||
}
|
||||
|
||||
/** Account first, then applet: isolates accounts from each other, and applets within an account. */
|
||||
private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000"
|
||||
|
||||
private fun keyOf(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
) = stringPreferencesKey("$coordinate\u0000$key")
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
}
|
||||
|
||||
+105
-40
@@ -40,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
|
||||
@@ -50,7 +49,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -78,32 +77,37 @@ import kotlinx.coroutines.launch
|
||||
class NappletBrokerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
|
||||
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
// Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the
|
||||
// Connected Apps screens revoke the very grants this broker consults; session grants are dropped
|
||||
// in onDestroy, which is the "all applet surfaces closed" boundary.
|
||||
private val ledger get() = Amethyst.instance.nappletPermissionLedger
|
||||
|
||||
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
|
||||
// instantiated in the main process where the signer lives; never touched from :napplet.
|
||||
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
|
||||
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
|
||||
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
// The broker for the current account, rebuilt only on account switch (see broker()).
|
||||
private var cachedBroker: Pair<Account, NappletBroker>? = null
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId; reads the current account live.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() }
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions()
|
||||
|
||||
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
|
||||
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
|
||||
|
||||
// Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet.
|
||||
// Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the
|
||||
// app's active one: a surface acts as the account that opened it for its whole life, so switching
|
||||
// accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here
|
||||
// would tell a page it had become someone else while its signatures still came back as the
|
||||
// original — the same desync the launch binding exists to prevent. What this does still report is
|
||||
// that account going away (logout/removal), which emits "".
|
||||
private val identityWatch =
|
||||
NappletIdentityWatch(scope) {
|
||||
Amethyst.instance.sessionManager.accountContent
|
||||
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
|
||||
NappletIdentityWatch(scope) { boundPubKey ->
|
||||
Amethyst.instance.accountsCache.accounts
|
||||
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
|
||||
}
|
||||
|
||||
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
|
||||
@@ -114,6 +118,13 @@ class NappletBrokerService : Service() {
|
||||
override fun onDestroy() {
|
||||
liveSubscriptions.closeAll()
|
||||
identityWatch.stop()
|
||||
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
|
||||
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
|
||||
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
|
||||
// the consent dialog promises ("allow for this session") instead of letting it become
|
||||
// "allow until the app process dies".
|
||||
dropCachedBroker()
|
||||
Amethyst.instance.nappletPermissionLedger.endSession()
|
||||
// Drop any foreground holds this broker still owns so they don't leak past the service.
|
||||
synchronized(foregroundLeases) {
|
||||
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
|
||||
@@ -241,7 +252,10 @@ class NappletBrokerService : Service() {
|
||||
val replyTo = msg.replyTo ?: return true
|
||||
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
|
||||
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
|
||||
// Bind to the account active at mint time: a browser token minted for one account must
|
||||
// never sign as another if the user switches while the page is still open.
|
||||
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
|
||||
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
|
||||
val response =
|
||||
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
|
||||
this.data =
|
||||
@@ -278,17 +292,20 @@ class NappletBrokerService : Service() {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
val broker = broker()
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
|
||||
is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
@@ -327,28 +344,44 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The launched-as account, or null once it is no longer loaded. */
|
||||
private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey]
|
||||
|
||||
/**
|
||||
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
|
||||
* changes (reference identity). The gateways capture the account and read its flows live, so a
|
||||
* cached broker stays correct across requests without per-request allocation.
|
||||
* The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey],
|
||||
* never whichever account is active right now.
|
||||
*
|
||||
* Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a
|
||||
* separate activity that an account switch does not tear down, so its WebView kept account A's
|
||||
* cookies while requests were signed by B. The page displayed one identity while another signed,
|
||||
* and B's session was written into A's storage jar — after which even the embedded tab, which is
|
||||
* rebuilt correctly, showed the wrong account.
|
||||
*
|
||||
* Binding to the launch account satisfies both halves of the rule with no extra machinery:
|
||||
* embedded surfaces are torn down and re-minted on a switch, so they follow the active account,
|
||||
* while a full-screen surface stays on the account it was opened with.
|
||||
*
|
||||
* Returns null when that account is no longer loaded (logged out), so requests fail closed
|
||||
* rather than silently falling back to someone else's key.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun broker(): NappletBroker? {
|
||||
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
|
||||
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
|
||||
val broker =
|
||||
AccountNappletGateways(
|
||||
account = account,
|
||||
context = applicationContext,
|
||||
ledger = ledger,
|
||||
storage = storage,
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
signerLedger = signerLedger,
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
private fun brokerFor(accountPubKey: HexKey): NappletBroker? {
|
||||
val account = accountFor(accountPubKey) ?: return null
|
||||
synchronized(brokerLock) {
|
||||
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
|
||||
val broker =
|
||||
AccountNappletGateways(
|
||||
account = account,
|
||||
context = applicationContext,
|
||||
ledger = ledger,
|
||||
storage = storage,
|
||||
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
|
||||
// through Tor when asked + active, and falls back to clearnet otherwise.
|
||||
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
|
||||
signerLedger = signerLedger,
|
||||
).broker()
|
||||
cachedBroker = account to broker
|
||||
return broker
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,6 +436,38 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Guards [cachedBroker]. Both live on the companion rather than the service instance so the
|
||||
* Connected Apps UI can reach the running broker to revoke its live session grants — the
|
||||
* screens are plain composables with no binder to this service, and the broker is the only
|
||||
* holder of the in-memory "allow for this session" signer grants.
|
||||
*
|
||||
* Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets
|
||||
* its own (unused, empty) copy of these statics and must never touch them.
|
||||
*/
|
||||
private val brokerLock = Any()
|
||||
|
||||
// The broker for the current account, rebuilt only on account switch (see brokerFor()).
|
||||
private var cachedBroker: Pair<Account, NappletBroker>? = null
|
||||
|
||||
/**
|
||||
* Drops the live "allow for this session" signer grants the running broker holds for
|
||||
* [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in
|
||||
* Connected Apps: without it the persisted grants are cleared but the in-memory session ones
|
||||
* keep authorizing signatures until the broker dies, so a revoked app goes on signing.
|
||||
*
|
||||
* No-op when no broker has been built yet (no applet has run this process).
|
||||
*/
|
||||
suspend fun revokeSessionGrants(coordinate: String) {
|
||||
val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return
|
||||
broker.revokeSessionGrants(coordinate)
|
||||
}
|
||||
|
||||
/** Forgets the cached broker, dropping every session grant it holds. */
|
||||
private fun dropCachedBroker() {
|
||||
synchronized(brokerLock) { cachedBroker = null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
|
||||
* carried in the identity's identifier (which the consent dialog shows); this constant only fills
|
||||
|
||||
+74
-8
@@ -23,15 +23,19 @@ package com.vitorpamplona.amethyst.napplet
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
@@ -41,11 +45,18 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -54,6 +65,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
/**
|
||||
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
|
||||
)
|
||||
}
|
||||
|
||||
// Operation detail box (may include content preview)
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
// Operation detail box (may include content preview), plus the full event behind a
|
||||
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
|
||||
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
|
||||
if (info.operationSummary.isNotBlank() || info.rawData.isNotBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
if (info.operationSummary.isNotBlank()) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.operationSummary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
// A single-account follow/mute change: show who, so the user recognizes
|
||||
// the face rather than parsing a name they may not read carefully.
|
||||
info.subject?.let { subject ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = subject.pubKey,
|
||||
model = subject.pictureUrl,
|
||||
contentDescription = subject.name,
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape),
|
||||
loadProfilePicture = true,
|
||||
loadRobohash = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
subject.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (info.rawData.isNotBlank()) {
|
||||
var showRawData by remember { mutableStateOf(false) }
|
||||
if (showRawData) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Surface(modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
info.rawData,
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { showRawData = !showRawData }) {
|
||||
Text(
|
||||
if (showRawData) {
|
||||
stringResource(R.string.napplet_consent_hide_event)
|
||||
} else {
|
||||
stringResource(R.string.napplet_consent_show_event)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -36,6 +36,26 @@ data class NappletConsentInfo(
|
||||
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
|
||||
val allowAlways: Boolean,
|
||||
val iconUrl: String? = null,
|
||||
/**
|
||||
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
|
||||
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
|
||||
* rendering — it truncates content and cannot spell out every tag — so this is the only place
|
||||
* the user can see exactly what a signature would cover.
|
||||
*/
|
||||
val rawData: String = "",
|
||||
/**
|
||||
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
|
||||
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
|
||||
* Null for multi-account edits and every other request.
|
||||
*/
|
||||
val subject: ConsentSubject? = null,
|
||||
)
|
||||
|
||||
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
|
||||
data class ConsentSubject(
|
||||
val pubKey: String,
|
||||
val name: String,
|
||||
val pictureUrl: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+200
-4
@@ -21,22 +21,35 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
|
||||
/**
|
||||
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
|
||||
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
|
||||
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
|
||||
* or a sat amount). Localized via app resources.
|
||||
*
|
||||
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
|
||||
* already cached there, so the dialog can say what a signature would actually change. It never signs,
|
||||
* mutates, or exposes account state — the values it reads are the user's own public lists.
|
||||
*/
|
||||
class NappletConsentSummary(
|
||||
private val context: Context,
|
||||
private val account: Account,
|
||||
) {
|
||||
fun info(
|
||||
identity: NappletIdentity,
|
||||
@@ -51,16 +64,196 @@ class NappletConsentSummary(
|
||||
} else {
|
||||
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
|
||||
}
|
||||
val consequence = consequenceFor(request)
|
||||
return NappletConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
capabilityLabel = context.getString(capability.labelRes()),
|
||||
operationSummary = summaryFor(request),
|
||||
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
|
||||
allowAlways = capability.canGrantAlways,
|
||||
iconUrl = iconUrl,
|
||||
rawData = rawEventFor(request),
|
||||
subject = consequence?.subject,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
|
||||
* signing requests carry one; everything else has nothing to disclose.
|
||||
*/
|
||||
private fun rawEventFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
|
||||
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun rawEvent(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
createdAt: Long?,
|
||||
): String =
|
||||
buildString {
|
||||
append("kind: ").append(kind).append('\n')
|
||||
createdAt?.let { append("created_at: ").append(it).append('\n') }
|
||||
append("tags:")
|
||||
if (tags.isEmpty()) {
|
||||
append(" []\n")
|
||||
} else {
|
||||
append('\n')
|
||||
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
|
||||
}
|
||||
append("content: ").append(content.ifEmpty { "(empty)" })
|
||||
}
|
||||
|
||||
/** A consequence line, plus the single account it is about when the change names exactly one. */
|
||||
private data class Consequence(
|
||||
val text: String,
|
||||
val subject: ConsentSubject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
|
||||
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
|
||||
* whole social graph — the summary would be technically true and practically useless.
|
||||
*/
|
||||
private fun consequenceFor(request: NappletRequest): Consequence? =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
|
||||
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun consequenceFor(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
): Consequence? =
|
||||
when (kind) {
|
||||
ContactListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.kind3FollowList.getFollowListEvent()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_follows,
|
||||
added = R.plurals.napplet_consent_diff_follow_added,
|
||||
removed = R.plurals.napplet_consent_diff_follow_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_follow_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
|
||||
)
|
||||
AdvertisedRelayListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.nip65RelayList.getNIP65RelayList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "r",
|
||||
template = R.string.napplet_consent_diff_relays,
|
||||
added = R.plurals.napplet_consent_diff_relay_added,
|
||||
removed = R.plurals.napplet_consent_diff_relay_removed,
|
||||
)
|
||||
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
|
||||
// and so cannot be diffed here.
|
||||
MuteListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.muteList.getMuteList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_mutes,
|
||||
added = R.plurals.napplet_consent_diff_mute_added,
|
||||
removed = R.plurals.napplet_consent_diff_mute_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_mute_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unmute_one,
|
||||
)
|
||||
// Deletions have no prior version to compare against — the tags are the whole request.
|
||||
DeletionEvent.KIND ->
|
||||
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
|
||||
?.let { Consequence(it) }
|
||||
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
|
||||
// content preview never reads as "there is nothing else here".
|
||||
else ->
|
||||
if (tags.isNotEmpty()) {
|
||||
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes what a proposed replaceable list changes relative to the copy already on the account.
|
||||
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
|
||||
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
|
||||
* nothing is cached to compare against.
|
||||
*/
|
||||
private fun diffOf(
|
||||
current: Array<Array<String>>?,
|
||||
proposed: Array<Array<String>>,
|
||||
tagName: String,
|
||||
@StringRes template: Int,
|
||||
@PluralsRes added: Int,
|
||||
@PluralsRes removed: Int,
|
||||
@StringRes oneAdded: Int? = null,
|
||||
@StringRes oneRemoved: Int? = null,
|
||||
): Consequence {
|
||||
val next = valuesOf(proposed, tagName)
|
||||
val previous =
|
||||
current?.let { valuesOf(it, tagName) }
|
||||
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
|
||||
|
||||
val addedKeys = next.filter { it !in previous }
|
||||
val removedKeys = previous.filter { it !in next }
|
||||
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
|
||||
return Consequence(context.getString(R.string.napplet_consent_diff_none))
|
||||
}
|
||||
|
||||
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
|
||||
// account is far more use than "follows 1 new account" — the user can tell at a glance
|
||||
// whether it is who they expected.
|
||||
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
|
||||
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
|
||||
}
|
||||
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
|
||||
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
|
||||
}
|
||||
|
||||
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
|
||||
val summary =
|
||||
if (parts.size == 2) {
|
||||
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
|
||||
} else {
|
||||
parts.first()
|
||||
}
|
||||
return Consequence(context.getString(template, summary))
|
||||
}
|
||||
|
||||
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
|
||||
private fun subjectOf(pubKey: String): ConsentSubject? {
|
||||
val user = account.cache.getUserIfExists(pubKey) ?: return null
|
||||
return ConsentSubject(
|
||||
pubKey = pubKey,
|
||||
name = user.toBestDisplayName(),
|
||||
pictureUrl = user.profilePicture(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The distinct values of every `[tagName, value, …]` tag. */
|
||||
private fun valuesOf(
|
||||
tags: Array<Array<String>>,
|
||||
tagName: String,
|
||||
): Set<String> {
|
||||
val out = mutableSetOf<String>()
|
||||
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pluralFor(
|
||||
resId: Int,
|
||||
count: Int,
|
||||
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
|
||||
|
||||
private fun countTag(
|
||||
tags: Array<Array<String>>,
|
||||
name: String,
|
||||
): Int = tags.count { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
private fun summaryFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
|
||||
@@ -91,11 +284,14 @@ class NappletConsentSummary(
|
||||
}
|
||||
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
|
||||
is NappletRequest.PayInvoice -> {
|
||||
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
|
||||
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
|
||||
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
|
||||
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
|
||||
if (sats != null) {
|
||||
if (sats != null && sats > 0) {
|
||||
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_pay)
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
|
||||
@@ -39,15 +39,18 @@ import kotlinx.coroutines.launch
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: () -> Flow<String>,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
fun start(push: (String) -> Unit) {
|
||||
fun start(
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
stop()
|
||||
job =
|
||||
scope.launch {
|
||||
pubKey()
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import java.security.SecureRandom
|
||||
|
||||
@@ -45,6 +46,18 @@ object NappletLaunchRegistry {
|
||||
data class Session(
|
||||
val identity: NappletIdentity,
|
||||
val declared: Set<NappletCapability>,
|
||||
/**
|
||||
* The account this surface was launched as. Requests resolve their signer through THIS, not
|
||||
* through whichever account happens to be active when they arrive.
|
||||
*
|
||||
* A full-screen host is a separate activity that an account switch does not tear down, so
|
||||
* resolving live meant its WebView kept account A's cookies while the broker signed as B —
|
||||
* a page showing one identity while another signed, and B's session written into A's
|
||||
* storage jar. Binding here gives both halves of the rule for free: embedded surfaces are
|
||||
* rebuilt on a switch, so they re-mint and follow the active account, while a full-screen
|
||||
* surface keeps the account it was opened with.
|
||||
*/
|
||||
val accountPubKey: HexKey,
|
||||
)
|
||||
|
||||
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
|
||||
@@ -60,9 +73,10 @@ object NappletLaunchRegistry {
|
||||
fun register(
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability>,
|
||||
accountPubKey: HexKey,
|
||||
): String {
|
||||
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
|
||||
sessions[token] = Session(identity, declared)
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,16 @@ object NappletLauncher {
|
||||
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
|
||||
val declared = profile.declaredCapabilities(requires)
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared)
|
||||
// Bound to the account launching it, so the surface keeps signing as that account even if the
|
||||
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
|
||||
// An empty key can never match a loaded account, so a launch with nobody signed in fails
|
||||
// closed at the broker rather than falling back to whoever signs in later.
|
||||
val launchAccountPubKey =
|
||||
Amethyst.instance.sessionManager
|
||||
.loggedInAccount()
|
||||
?.pubKey
|
||||
.orEmpty()
|
||||
val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey)
|
||||
|
||||
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
|
||||
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
|
||||
@@ -156,6 +165,9 @@ object NappletLauncher {
|
||||
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
|
||||
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
|
||||
putString(NappletHostContract.EXTRA_THEME, theme)
|
||||
// Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies
|
||||
// and localStorage into another. Derived here (the sandbox never sees the pubkey).
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -38,12 +38,13 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
|
||||
* caller-supplied sink — it never touches the transport itself.
|
||||
*
|
||||
* [account] is read live (so it always targets the currently signed-in account); [open] is reached
|
||||
* only after the broker authorized the subscription (RELAY consent).
|
||||
* The account is supplied per [open] by the caller, which resolves it from the requesting surface's
|
||||
* LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an
|
||||
* account switch, and reading live would have pointed its REQs at the new account's relays while its
|
||||
* signatures still came from the old one. [open] is reached only after the broker authorized the
|
||||
* subscription (RELAY consent).
|
||||
*/
|
||||
class NappletLiveSubscriptions(
|
||||
private val account: () -> Account?,
|
||||
) {
|
||||
class NappletLiveSubscriptions {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -62,9 +63,9 @@ class NappletLiveSubscriptions(
|
||||
fun open(
|
||||
nappletSubId: String,
|
||||
filters: List<Filter>,
|
||||
account: Account?,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
val account = account()
|
||||
val relays = account?.homeRelays?.flow?.value ?: emptySet()
|
||||
if (account == null || filters.isEmpty() || relays.isEmpty()) {
|
||||
push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.napplet
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies,
|
||||
* localStorage, IndexedDB and service workers by.
|
||||
*
|
||||
* Every embedded app follows the currently-selected account, and each account gets its OWN storage
|
||||
* jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact
|
||||
* (this is a partition, not a wipe).
|
||||
*
|
||||
* The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which
|
||||
* account it is running for — it only gets a stable, meaningless token. Stability is what makes
|
||||
* sessions survive a switch, so the derivation must never change once shipped.
|
||||
*
|
||||
* The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape
|
||||
* before handing it to `ProfileStore`.
|
||||
*/
|
||||
object NappletWebViewProfiles {
|
||||
/** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */
|
||||
private const val DOMAIN = "amethyst-webview-profile-v1:"
|
||||
|
||||
/** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */
|
||||
private const val NAME_LENGTH = 32
|
||||
|
||||
/** The profile name for the account embedded apps currently run as, or null when logged out. */
|
||||
fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope())
|
||||
|
||||
/** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */
|
||||
fun forPubKey(pubKey: HexKey): String? {
|
||||
if (pubKey.isBlank()) return null
|
||||
|
||||
return MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest((DOMAIN + pubKey).toByteArray())
|
||||
.toHexKey()
|
||||
.take(NAME_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,19 @@ import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** Human-readable label for a [NostrSignerOp]. */
|
||||
@@ -41,8 +45,24 @@ fun NostrSignerOp.label(context: Context): String =
|
||||
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
|
||||
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's display name for a consent prompt: their profile name when we have it cached, otherwise
|
||||
* a shortened npub. Never empty — "read your private messages with <nothing>" would be worse than the
|
||||
* broad wording it replaces.
|
||||
*/
|
||||
fun counterpartyLabel(pubKeyHex: HexKey): String {
|
||||
LocalCache
|
||||
.getUserIfExists(pubKeyHex)
|
||||
?.toBestDisplayName()
|
||||
?.ifBlank { null }
|
||||
?.let { return it }
|
||||
val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull()
|
||||
return if (npub != null) npub.take(12) + "…" else pubKeyHex.take(12) + "…"
|
||||
}
|
||||
|
||||
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
|
||||
fun buildSignerConsentInfo(
|
||||
context: Context,
|
||||
@@ -105,10 +125,16 @@ fun buildSignerConsentInfo(
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates a [SignerConnectInfo] for the first-connect dialog. */
|
||||
/**
|
||||
* Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the
|
||||
* connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as
|
||||
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
|
||||
* approve a set it never showed them.
|
||||
*/
|
||||
fun buildConnectInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability> = emptySet(),
|
||||
): SignerConnectInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
@@ -124,5 +150,18 @@ fun buildConnectInfo(
|
||||
} else {
|
||||
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" }
|
||||
}
|
||||
return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
|
||||
// Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and
|
||||
// VALUE is per-use, so listing them would overstate what accepting hands over.
|
||||
val preGranted =
|
||||
declared
|
||||
.filter { it.requiresConsent && !it.requiresPerUseConsent }
|
||||
.map { context.getString(it.labelRes()) }
|
||||
.sorted()
|
||||
return SignerConnectInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
domain = domain,
|
||||
iconUrl = iconUrl,
|
||||
requestedPermissions = preGranted,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-3
@@ -81,7 +81,9 @@ class AccountNappletGateways(
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
private val signerLedger: NostrSignerPermissionLedger? = null,
|
||||
) {
|
||||
private val consentSummary = NappletConsentSummary(context)
|
||||
// Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays,
|
||||
// mutes) against the copy already cached here, and say what actually changes.
|
||||
private val consentSummary = NappletConsentSummary(context, account)
|
||||
|
||||
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
|
||||
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
|
||||
@@ -138,10 +140,10 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val connectPrompt =
|
||||
NostrConnectPrompt { identity ->
|
||||
NostrConnectPrompt { identity, declared ->
|
||||
SignerConnectCoordinator.requestConnect(
|
||||
context = context,
|
||||
info = buildConnectInfo(context, identity),
|
||||
info = buildConnectInfo(context, identity, declared),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* it also picks up NUT-02 per-input fee handling for free.
|
||||
*/
|
||||
class MeltProcessor {
|
||||
/**
|
||||
* @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token
|
||||
* pointing at one of those was, by definition, issued by a mint the user
|
||||
* deliberately added, so it is exempt from the private-address block that
|
||||
* [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies
|
||||
* to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate).
|
||||
*/
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
context: Context,
|
||||
knownWalletMints: Set<String> = emptySet(),
|
||||
): MeltResult {
|
||||
try {
|
||||
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
|
||||
val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) }
|
||||
val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient))
|
||||
val proofs = token.proofs
|
||||
|
||||
// A Lightning address must commit to an amount before we know the
|
||||
@@ -106,6 +116,14 @@ class MeltProcessor {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
if (e is LightningAddressResolver.LightningAddressError) throw e
|
||||
// The mint URL was refused before any request went out: this is OUR
|
||||
// message, not the mint's, so don't dress it up as "the mint said".
|
||||
if (e is MintUrlException) {
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url),
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
|
||||
)
|
||||
}
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
|
||||
+28
-7
@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -51,20 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// Backward-paging boundary: once the feed has filled a page, ask for everything older than
|
||||
// its oldest card. It stays null until then — see the note on the missing week floor below,
|
||||
// which is what let it stay null forever on a quiet inbox.
|
||||
val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
|
||||
|
||||
val inbox =
|
||||
key.account.notificationRelays.flow.value.flatMap {
|
||||
// No `since` floor on the first fetch. These filters are scoped by `#p` to my own
|
||||
// key and carry a relay-side `limit`, so an all-time query costs one index scan and
|
||||
// returns at most `limit` events, newest first — exactly what Home does (it passes
|
||||
// `since ?: boundary`, i.e. null on a cold start).
|
||||
//
|
||||
// This used to fall back to `oneWeekAgo()`, which silently emptied the tab for
|
||||
// anyone whose last mention was older than a week: EOSE `since` is in-memory only,
|
||||
// so EVERY cold start re-pinned the window to 7 days, and the paging boundary above
|
||||
// could never rescue it — it only arms once the feed holds a full page, and the feed
|
||||
// could not fill because the query only ever asked for a week. A fresh install of an
|
||||
// established account hit the same deadlock.
|
||||
val notificationSince = since?.get(it)?.time ?: pagingBoundary
|
||||
|
||||
filterSummaryNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
since = notificationSince,
|
||||
) +
|
||||
filterNotificationsToPubkey(
|
||||
relay = it,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
// Fixed one-week live tail. Everything older is paged on demand by the marker-driven
|
||||
// [AccountNotificationsHistoryEoseManager] (until+limit, per relay) — the NIP-04 model —
|
||||
// so this filter no longer drifts its `since` back as the feed fills.
|
||||
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
since = notificationSince,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
relay = relay,
|
||||
pubkey = user(key).pubkeyHex,
|
||||
groupIds = groupIds.distinct(),
|
||||
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
|
||||
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
|
||||
// already bound this, so a week floor only hides older group activity.
|
||||
since = since?.get(relay)?.time ?: pagingBoundary,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +122,11 @@ class AccountNotificationsEoseFromInboxRelaysManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
|
||||
+11
-4
@@ -27,12 +27,12 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AccountNotificationsEoseFromRandomRelaysManager(
|
||||
@@ -50,9 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
|
||||
key: AccountQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// Fixed one-week live tail of stragglers from follow relays. Backward history is the marker-driven
|
||||
// [AccountNotificationsHistoryEoseManager]'s job (on inbox + group relays), so this no longer drifts.
|
||||
val defaultSince = TimeUtils.oneWeekAgo()
|
||||
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
|
||||
// behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20
|
||||
// newest either way — the floor only ever hid notifications older than a week, and since the
|
||||
// boundary above needs a full page to arm, a quiet inbox could never page past it.
|
||||
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
|
||||
return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
|
||||
val since = since?.get(it)?.time ?: defaultSince
|
||||
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)
|
||||
@@ -73,6 +75,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
|
||||
+127
-10
@@ -40,28 +40,129 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* caller should surface that a lightning wallet is required.
|
||||
*/
|
||||
object BlossomPaymentHandler {
|
||||
/**
|
||||
* Hard ceiling on a single BUD-07 charge, in sats.
|
||||
*
|
||||
* BUD-07 charges are per-blob storage fees: real paid Blossom servers ask
|
||||
* single-digit to low-hundreds of sats for a media upload. 10,000 sats is
|
||||
* roughly USD 10 at a 100k BTC — one to two orders of magnitude above any
|
||||
* legitimate per-blob fee, so it never gets in a real user's way, while
|
||||
* capping what a hostile or compromised server can drain in one prompt.
|
||||
* Anything above this is refused outright rather than shown to the user,
|
||||
* because the value is server-chosen and a user tapping through a dialog is
|
||||
* not a meaningful defence against a four-digit-sat surprise.
|
||||
*/
|
||||
const val MAX_PAYMENT_SATS = 10_000L
|
||||
|
||||
/** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */
|
||||
sealed interface PayResult {
|
||||
data class Paid(
|
||||
val proof: BlossomPaymentProof,
|
||||
) : PayResult
|
||||
|
||||
/** The amount failed [checkAmount]; [reason] is user-facing. */
|
||||
data class Refused(
|
||||
val reason: String,
|
||||
) : PayResult
|
||||
|
||||
/** No invoice, no wallet, or the wallet request could not be sent. */
|
||||
data object Unavailable : PayResult
|
||||
|
||||
/** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */
|
||||
data object TimedOut : PayResult
|
||||
}
|
||||
|
||||
/** Verdict on the invoice amount, before any money moves. */
|
||||
sealed interface AmountCheck {
|
||||
data class Ok(
|
||||
val sats: Long,
|
||||
) : AmountCheck
|
||||
|
||||
/** BOLT-11 with no amount: the payee picks it. Never payable unattended. */
|
||||
data object Amountless : AmountCheck
|
||||
|
||||
data class OverCap(
|
||||
val sats: Long,
|
||||
) : AmountCheck
|
||||
|
||||
/** The invoice asks for something other than what the dialog told the user. */
|
||||
data class Mismatch(
|
||||
val shownSats: Long?,
|
||||
val actualSats: Long,
|
||||
) : AmountCheck
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the amount from the invoice itself and checks it against both the
|
||||
* cap and [shownSats] — the number the confirmation dialog put in front of the
|
||||
* user. The amount shown must be the amount paid, so a server that swapped the
|
||||
* invoice (or leaned on a misleading `X-Reason`) cannot get a different sum
|
||||
* approved than the one the user agreed to.
|
||||
*/
|
||||
fun checkAmount(
|
||||
payment: BlossomPaymentRequired,
|
||||
shownSats: Long?,
|
||||
): AmountCheck {
|
||||
val actual = amountSats(payment) ?: return AmountCheck.Amountless
|
||||
if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual)
|
||||
if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual)
|
||||
return AmountCheck.Ok(actual)
|
||||
}
|
||||
|
||||
/** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */
|
||||
fun refusalReason(check: AmountCheck): String =
|
||||
when (check) {
|
||||
is AmountCheck.Ok -> ""
|
||||
is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it."
|
||||
is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid."
|
||||
is AmountCheck.Mismatch ->
|
||||
"The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid."
|
||||
}
|
||||
|
||||
/** True when this account has a wallet we can pay the lightning invoice with. */
|
||||
fun canPay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
|
||||
|
||||
/** The invoice amount in sats, for display in a confirmation prompt. */
|
||||
/**
|
||||
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
|
||||
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
|
||||
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
|
||||
* unspecified and chosen by the payee.
|
||||
*/
|
||||
fun amountSats(payment: BlossomPaymentRequired): Long? =
|
||||
payment.lightning?.let {
|
||||
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
|
||||
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if
|
||||
* there is no payable invoice, no wallet, or the wallet didn't confirm in time.
|
||||
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof.
|
||||
*
|
||||
* [shownSats] is what the confirmation dialog displayed; the invoice is
|
||||
* re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise
|
||||
* nothing is sent to the wallet at all.
|
||||
*/
|
||||
suspend fun pay(
|
||||
account: Account,
|
||||
payment: BlossomPaymentRequired,
|
||||
): BlossomPaymentProof? {
|
||||
val invoice = payment.lightning ?: return null
|
||||
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
|
||||
shownSats: Long?,
|
||||
): PayResult {
|
||||
val invoice = payment.lightning ?: return PayResult.Unavailable
|
||||
if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable
|
||||
|
||||
val check = checkAmount(payment, shownSats)
|
||||
if (check !is AmountCheck.Ok) {
|
||||
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
|
||||
return PayResult.Refused(refusalReason(check))
|
||||
}
|
||||
|
||||
// Never send the same invoice twice: an earlier attempt may still settle.
|
||||
if (!InFlightInvoices.tryClaim(invoice)) {
|
||||
return PayResult.Refused(
|
||||
"A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.",
|
||||
)
|
||||
}
|
||||
|
||||
val preimageResult = CompletableDeferred<String?>()
|
||||
try {
|
||||
@@ -71,10 +172,26 @@ object BlossomPaymentHandler {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
|
||||
return null
|
||||
// The request never left, so the invoice is definitively not in flight.
|
||||
InFlightInvoices.release(invoice)
|
||||
return PayResult.Unavailable
|
||||
}
|
||||
|
||||
val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null
|
||||
return BlossomPaymentProof(lightningPreimage = preimage)
|
||||
// NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout
|
||||
// cannot stop the payment — it can only stop us from sending it again.
|
||||
// Deliberately do NOT release the claim on the timeout path.
|
||||
val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() }
|
||||
if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut
|
||||
|
||||
InFlightInvoices.release(invoice)
|
||||
val preimage = answered ?: return PayResult.Unavailable
|
||||
return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage))
|
||||
}
|
||||
|
||||
/**
|
||||
* How long we wait for the wallet. Matches the previous behaviour; note the
|
||||
* NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach
|
||||
* us anyway — which is exactly why the invoice stays blocked afterwards.
|
||||
*/
|
||||
private const val PAYMENT_TIMEOUT_MS = 90_000L
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
/**
|
||||
* BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned.
|
||||
*
|
||||
* [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but
|
||||
* NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so
|
||||
* giving up on the wait does **not** stop the payment. An invoice that settles a
|
||||
* second after we time out still moved the user's money — and if we then let the
|
||||
* user (or an automatic retry) send the very same invoice again, they pay twice.
|
||||
*
|
||||
* So: claim the invoice before sending it, and release the claim only when the
|
||||
* wallet gives a definitive answer. A timed-out invoice stays claimed for the
|
||||
* life of the process and can never be paid a second time from this app.
|
||||
*
|
||||
* This is the weaker half of the fix — a genuine cancel would be better, but the
|
||||
* NIP-47 client offers none, so we settle for "never silently pay it twice".
|
||||
*/
|
||||
object InFlightInvoices {
|
||||
private val claimed = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Claims [invoice] for one payment attempt. Returns false when it was already
|
||||
* claimed and never resolved — the caller must not send it again.
|
||||
*/
|
||||
fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) }
|
||||
|
||||
/** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */
|
||||
fun release(invoice: String) {
|
||||
synchronized(claimed) { claimed.remove(invoice) }
|
||||
}
|
||||
|
||||
/** True when [invoice] was sent to the wallet and never resolved. */
|
||||
fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed }
|
||||
|
||||
/** Test-only reset. */
|
||||
internal fun clear() {
|
||||
synchronized(claimed) { claimed.clear() }
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.uploads.blossom
|
||||
|
||||
/**
|
||||
* Bounds how often one user action may raise a BUD-07 payment prompt.
|
||||
*
|
||||
* The mirror flow retries a target after paying it, and that retry catches every
|
||||
* exception — including a second `BlossomPaymentException`. Left unbounded, a
|
||||
* server that pockets the preimage and answers 402 again drives an indefinite
|
||||
* pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real
|
||||
* payment, so "the user can always tap cancel" is not an adequate answer.
|
||||
*
|
||||
* Rule: a given (blob, server) pair may prompt at most once per user-initiated
|
||||
* action. [beginUserAction] resets the ledger; everything downstream of that tap
|
||||
* — including the post-payment retry — goes through [shouldPrompt].
|
||||
*/
|
||||
class PaymentPromptLedger {
|
||||
private val prompted = mutableSetOf<String>()
|
||||
|
||||
/** The user tapped mirror/sync: a fresh budget of one prompt per target. */
|
||||
fun beginUserAction() {
|
||||
synchronized(prompted) { prompted.clear() }
|
||||
}
|
||||
|
||||
/**
|
||||
* True the first time this (blob, server) pair asks for payment in the current
|
||||
* user action; false on every subsequent 402 from the same pair.
|
||||
*/
|
||||
fun shouldPrompt(
|
||||
hash: String,
|
||||
server: String,
|
||||
): Boolean = synchronized(prompted) { prompted.add("$hash|$server") }
|
||||
}
|
||||
+17
-8
@@ -68,6 +68,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -105,7 +106,7 @@ fun BlossomBlobManagerScreen(
|
||||
BlossomPaymentDialog(
|
||||
host = pending.targetHost,
|
||||
amountSats = pending.amountSats,
|
||||
reason = pending.payment.reason,
|
||||
reason = pending.payment.sanitizedReason(),
|
||||
onConfirm = { vm.confirmPendingPayment() },
|
||||
onDismiss = { vm.cancelPendingPayment() },
|
||||
)
|
||||
@@ -419,13 +420,21 @@ private fun BlossomPaymentDialog(
|
||||
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
|
||||
title = { Text(stringRes(R.string.blossom_payment_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text =
|
||||
listOfNotNull(
|
||||
stringRes(R.string.blossom_payment_message, host),
|
||||
reason,
|
||||
).joinToString("\n\n"),
|
||||
)
|
||||
Column {
|
||||
Text(text = stringRes(R.string.blossom_payment_message, host))
|
||||
// X-Reason is server-controlled: it is sanitized upstream and rendered
|
||||
// here attributed to the server, in a dimmer italic, so it can never be
|
||||
// mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat").
|
||||
reason?.let {
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.blossom_payment_server_says, host, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(onClick = onConfirm) {
|
||||
|
||||
+54
-9
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
@@ -129,6 +130,13 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
|
||||
private var resultCollectorStarted = false
|
||||
|
||||
/**
|
||||
* Caps BUD-07 payment prompts at one per (blob, server) per user-initiated
|
||||
* mirror, so a server that keeps replying 402 after being paid cannot drive an
|
||||
* unbounded pay-prompt cycle. See [PaymentPromptLedger].
|
||||
*/
|
||||
private val promptLedger = PaymentPromptLedger()
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.account = accountViewModel.account
|
||||
// Reflect the app-level sync sweep's per-server results onto the pills, so an
|
||||
@@ -299,8 +307,17 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */
|
||||
/**
|
||||
* BUD-04: mirror a blob to every server that doesn't have it; each pill spins
|
||||
* then turns green. This is the user-initiated entry point, so it resets the
|
||||
* "already asked for payment" ledger — see [promptedForPayment].
|
||||
*/
|
||||
fun mirrorToMissing(row: BlobRow) {
|
||||
promptLedger.beginUserAction()
|
||||
mirrorMissingTargets(row)
|
||||
}
|
||||
|
||||
private fun mirrorMissingTargets(row: BlobRow) {
|
||||
val source = row.url ?: return
|
||||
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
|
||||
if (targets.isEmpty()) return
|
||||
@@ -313,6 +330,14 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
} catch (e: BlossomPaymentException) {
|
||||
setServerState(row.hash, target, PresenceState.MISSING)
|
||||
if (BlossomPaymentHandler.canPay(account, e.payment)) {
|
||||
// Bounded: a server that pockets the preimage and replies 402
|
||||
// again must not be able to spin up an endless pay-prompt
|
||||
// cycle. One prompt per target per user-initiated mirror.
|
||||
if (!promptLedger.shouldPrompt(row.hash, target)) {
|
||||
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
|
||||
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
|
||||
continue
|
||||
}
|
||||
_pendingPayment.value =
|
||||
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
|
||||
return@launch
|
||||
@@ -369,12 +394,31 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
_pendingPayment.value = null
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
setServerState(pending.hash, pending.target, PresenceState.PENDING)
|
||||
val proof = BlossomPaymentHandler.pay(account, pending.payment)
|
||||
if (proof == null) {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value = "Payment failed or was not confirmed by the wallet."
|
||||
return@launch
|
||||
}
|
||||
// pending.amountSats is exactly what the dialog showed; pay() re-derives
|
||||
// the amount from the invoice and refuses if the two disagree or the
|
||||
// amount is above the cap.
|
||||
val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats)
|
||||
val proof =
|
||||
when (result) {
|
||||
is BlossomPaymentHandler.PayResult.Paid -> result.proof
|
||||
is BlossomPaymentHandler.PayResult.Refused -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value = result.reason
|
||||
return@launch
|
||||
}
|
||||
BlossomPaymentHandler.PayResult.TimedOut -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value =
|
||||
"Your wallet did not confirm in time. If the payment does go through, retry the mirror — " +
|
||||
"Amethyst will not send this invoice again."
|
||||
return@launch
|
||||
}
|
||||
BlossomPaymentHandler.PayResult.Unavailable -> {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
_error.value = "Payment failed or was not confirmed by the wallet."
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
try {
|
||||
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
|
||||
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
|
||||
@@ -382,8 +426,9 @@ class BlossomBlobManagerViewModel : ViewModel() {
|
||||
setServerState(pending.hash, pending.target, PresenceState.MISSING)
|
||||
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
|
||||
}
|
||||
// Continue with any remaining missing servers (which may prompt again).
|
||||
currentRow(pending.hash)?.let { mirrorToMissing(it) }
|
||||
// Continue with any remaining missing servers. Targets already prompted
|
||||
// in this action (including this one) will NOT prompt again.
|
||||
currentRow(pending.hash)?.let { mirrorMissingTargets(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+60
-33
@@ -88,40 +88,13 @@ fun ConcordInviteCard(
|
||||
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
ConcordInvitePreviewRow(
|
||||
robotSeed = robotSeed,
|
||||
title = title,
|
||||
subtitle = stringRes(R.string.concord_invite_card_subtitle),
|
||||
accountViewModel = accountViewModel,
|
||||
autoPlayGif = autoPlayGif,
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robotSeed,
|
||||
model = null,
|
||||
contentDescription = title,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.concord_invite_card_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
SymbolIcon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = stringRes(R.string.concord_invite_card_join),
|
||||
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
|
||||
* the deep-link consent screen, so both render an invite identically. Purely
|
||||
* presentational — it performs no I/O, which is what lets the deep-link screen show a
|
||||
* preview without contacting the link's (attacker-supplied) relays before the user
|
||||
* consents.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordInvitePreviewRow(
|
||||
robotSeed: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
autoPlayGif: Boolean,
|
||||
trailing: @Composable () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robotSeed,
|
||||
model = null,
|
||||
contentDescription = title,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
|
||||
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
|
||||
@@ -334,6 +335,10 @@ fun AppNavigation(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
|
||||
.collectAsStateWithLifecycle()
|
||||
// Move every embedded app to the new account on a switch. Mounted before the layer and
|
||||
// the preloader so the previous account's sessions are dropped ahead of the first sweep
|
||||
// (an embed WebView's storage profile is fixed at construction, so it must be rebuilt).
|
||||
EmbeddedTabAccountWatcher()
|
||||
EmbeddedTabLayer(bottomBarItems.favoriteIds())
|
||||
// Warm every pinned tab at startup so the first tap is instant (content already local).
|
||||
EmbeddedTabPreloader(accountViewModel)
|
||||
|
||||
+42
-24
@@ -100,7 +100,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Zoom the map animates to after a search hit or a "use my location" tap. */
|
||||
private const val RECENTER_ZOOM = 14.0
|
||||
@@ -125,15 +124,16 @@ private fun zoomForGeohashLength(length: Int): Double =
|
||||
else -> 17.5
|
||||
}
|
||||
|
||||
/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */
|
||||
private const val WORLD_CENTER_LAT = 20.0
|
||||
private const val WORLD_CENTER_LON = 0.0
|
||||
|
||||
/**
|
||||
* Minimum center shift (degrees) from the opening center that counts as a real pan,
|
||||
* so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick.
|
||||
* Neutral starting center when the picker opens with no seed: a genuinely unnamed
|
||||
* point in the mid-Atlantic, framing the Americas, Europe and Africa at [WORLD_ZOOM].
|
||||
*
|
||||
* Deliberately NOT 20N/0E — that is inland Mali (it reverse-geocodes to Tessalit) and
|
||||
* sits exactly on the prime meridian, where a sub-kilometre pan flips the geohash
|
||||
* between the `e…` and `s…` halves of the world and looks like a broken readout.
|
||||
*/
|
||||
private const val SELECT_MOVE_EPS = 0.0005
|
||||
private const val WORLD_CENTER_LAT = 20.0
|
||||
private const val WORLD_CENTER_LON = -30.0
|
||||
|
||||
/** Give up waiting for a GPS fix after this long so the button never spins forever. */
|
||||
private const val GPS_FIX_TIMEOUT_MS = 20_000L
|
||||
@@ -201,15 +201,21 @@ fun GeohashLocationPickerContent(
|
||||
val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
|
||||
val seedLen = initialGeohash?.trim()?.length ?: 0
|
||||
|
||||
// The map opens centered here. Without a seed there is no real selection yet — and
|
||||
// osmdroid can emit an initial scroll at this exact center, which must NOT be treated
|
||||
// as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm).
|
||||
// The map opens centered here. Without a seed there is no real selection yet.
|
||||
val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT
|
||||
val initialLon = seed?.centerLon ?: WORLD_CENTER_LON
|
||||
|
||||
var pickedLat by remember { mutableStateOf(seed?.centerLat) }
|
||||
var pickedLon by remember { mutableStateOf(seed?.centerLon) }
|
||||
var hasSelection by remember { mutableStateOf(seed != null) }
|
||||
|
||||
// osmdroid emits a scroll event when the MapView is first laid out, reporting a
|
||||
// pixel-quantized version of the opening center — at world zoom a single pixel is
|
||||
// ~0.4 degrees, so that phantom "pan" can be hundreds of km away from where we asked
|
||||
// it to open. Treating it as a pick auto-selected whatever the default center was and
|
||||
// enabled Confirm with nothing chosen. Only map movement that follows a real finger
|
||||
// down on the map counts, so an automatic scroll can never become a selection.
|
||||
var mapTouched by remember { mutableStateOf(false) }
|
||||
var level by remember {
|
||||
mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY)
|
||||
}
|
||||
@@ -337,8 +343,8 @@ fun GeohashLocationPickerContent(
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||
LocationPickerMap(
|
||||
latitude = seed?.centerLat ?: 20.0,
|
||||
longitude = seed?.centerLon ?: 0.0,
|
||||
latitude = initialLat,
|
||||
longitude = initialLon,
|
||||
pickedLatitude = null,
|
||||
pickedLongitude = null,
|
||||
zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM,
|
||||
@@ -347,11 +353,12 @@ fun GeohashLocationPickerContent(
|
||||
zoomTo = zoomTo,
|
||||
highlight = highlight,
|
||||
highlightColor = highlightColor,
|
||||
onUserInteraction = { mapTouched = true },
|
||||
onCenterChanged = { lat, lon ->
|
||||
pickedLat = lat
|
||||
pickedLon = lon
|
||||
// A pan/zoom away from the opening center is the user's first real pick.
|
||||
if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) {
|
||||
// Only a movement the user drove counts as their first real pick.
|
||||
if (mapTouched) {
|
||||
pickedLat = lat
|
||||
pickedLon = lon
|
||||
hasSelection = true
|
||||
}
|
||||
},
|
||||
@@ -687,13 +694,24 @@ private fun PickerBottomBar(
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||
LoadCityName(geohashStr = settledCell ?: cell) { cityName ->
|
||||
Text(
|
||||
cityName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
// The place name must never contradict the geohash under it. Resolve
|
||||
// it only for the settled cell, and only while that IS the current
|
||||
// cell — mid-pan the debounced [settledCell] still names the previous
|
||||
// cell, and drawing it beside a fresh geohash is worse than no name.
|
||||
// LoadCityName echoes the geohash back when it cannot resolve a name
|
||||
// (no geocoder backend, or a point at sea); drop that too rather than
|
||||
// repeat the geohash as if it were a place.
|
||||
if (settledCell == cell) {
|
||||
LoadCityName(geohashStr = cell) { cityName ->
|
||||
if (cityName != cell) {
|
||||
Text(
|
||||
cityName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"#$cell",
|
||||
|
||||
+10
-1
@@ -65,6 +65,10 @@ import org.osmdroid.views.overlay.Polygon
|
||||
* - [recenter] animates the map to a new point when its value changes (e.g. after
|
||||
* a place search or a "use my location" tap). Passing the same value twice is a
|
||||
* no-op, so it is safe to hoist in state.
|
||||
* - [onUserInteraction] fires when a finger first lands on the map. [onCenterChanged]
|
||||
* alone cannot tell a user pan from osmdroid's own layout-time scroll (which the
|
||||
* MapView emits at the opening center with pixel-quantized coordinates), so callers
|
||||
* that must not treat an automatic scroll as a choice gate on this instead.
|
||||
*/
|
||||
@Composable
|
||||
fun LocationPickerMap(
|
||||
@@ -80,12 +84,14 @@ fun LocationPickerMap(
|
||||
highlight: BoundingBox? = null,
|
||||
highlightColor: Int = 0,
|
||||
onCenterChanged: ((Double, Double) -> Unit)? = null,
|
||||
onUserInteraction: (() -> Unit)? = null,
|
||||
onPick: (Double, Double) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnPick by rememberUpdatedState(onPick)
|
||||
val currentOnCenterChanged by rememberUpdatedState(onCenterChanged)
|
||||
val currentOnUserInteraction by rememberUpdatedState(onUserInteraction)
|
||||
val darkTheme = !MaterialTheme.colorScheme.isLight
|
||||
|
||||
// Tracks the last point we animated to, so a recomposition that re-supplies the
|
||||
@@ -117,7 +123,10 @@ fun LocationPickerMap(
|
||||
// LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap.
|
||||
setOnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true)
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.parent?.requestDisallowInterceptTouchEvent(true)
|
||||
currentOnUserInteraction?.invoke()
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
false
|
||||
|
||||
+42
-6
@@ -110,12 +110,16 @@ class UserSuggestionState(
|
||||
.map(::userSearchTermOrNull)
|
||||
.map { prefix ->
|
||||
if (prefix != null) {
|
||||
// NIP-05 resolution: user@domain or bare .bit domain
|
||||
// NIP-05 resolution: full `name@domain` form, or bare
|
||||
// `.bit` domain synthesised as the wildcard `_@domain`.
|
||||
// Bare DNS domains aren't accepted here on purpose: a
|
||||
// `.com`/`.io`/etc. that happens to host nostr.json is
|
||||
// ambiguous with a regular URL the user might be typing.
|
||||
val nip05 =
|
||||
if (prefix.contains('@')) {
|
||||
if (prefix.endsWith(".bit", ignoreCase = true) && !prefix.contains('@')) {
|
||||
Nip05Id.parseLenient(prefix)
|
||||
} else if (prefix.contains('@')) {
|
||||
Nip05Id.parse(prefix)
|
||||
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
|
||||
Nip05Id("_", prefix.lowercase())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -231,7 +235,7 @@ class UserSuggestionState(
|
||||
item: User,
|
||||
): TextFieldValue {
|
||||
val lastWordStart = message.selection.end - word.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
val wordToInsert = mentionInsertion(word, item)
|
||||
|
||||
return TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
|
||||
@@ -244,11 +248,43 @@ class UserSuggestionState(
|
||||
word: String,
|
||||
item: User,
|
||||
) {
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
val wordToInsert = mentionInsertion(word, item)
|
||||
state.edit {
|
||||
val lastWordStart = selection.end - word.length
|
||||
replace(lastWordStart, selection.end, wordToInsert)
|
||||
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to insert into the message text when the author picks [item]
|
||||
* from the suggestion popover. When the author was typing a NIP-05
|
||||
* mention (full `m@testls.bit` or bare-domain `.bit` form), we insert
|
||||
* `nostr:nprofile1…` directly so the send-time tagger doesn't need to
|
||||
* re-resolve anything — it parses the bech32 inline via its existing
|
||||
* `nprofile1` branch, with no main-thread I/O. For every other path
|
||||
* (search by name, typed npub/nprofile, hex) we keep the existing
|
||||
* `@npub1…` form to preserve current behaviour.
|
||||
*
|
||||
* Pre-resolved NIP-05 hits already have their relay hints pushed into
|
||||
* the account cache by [nip05ResolutionFlow] before this runs, so
|
||||
* [User.toNProfile] picks them up automatically.
|
||||
*/
|
||||
private fun mentionInsertion(
|
||||
word: String,
|
||||
item: User,
|
||||
): String {
|
||||
val typed = userSearchTermOrNull(word)
|
||||
val wasNip05Mention =
|
||||
typed != null &&
|
||||
(
|
||||
(typed.endsWith(".bit", ignoreCase = true) && !typed.contains('@')) ||
|
||||
(typed.contains('@') && Nip05Id.parse(typed) != null)
|
||||
)
|
||||
return if (wasNip05Mention) {
|
||||
"nostr:${item.toNProfile()} "
|
||||
} else {
|
||||
"@${item.pubkeyNpub()} "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +95,17 @@ fun RenderPodcastEpisode(
|
||||
val value = remember(noteEvent) { episode.episodeValue() }
|
||||
var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) }
|
||||
// Suppress the markdown block if blank — title + description already describe a short
|
||||
// episode. Otherwise hand off to RichText below.
|
||||
val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } }
|
||||
// episode — and ALSO when it merely repeats the description. Most feeds put the same text in
|
||||
// both the `description` tag and the event content, and the thread view renders both blocks
|
||||
// (`makeItShort` is false there), so the whole description appeared twice inside one card,
|
||||
// each copy with its own "Show More". Compared on collapsed whitespace so a copy differing
|
||||
// only in wrapping still counts as a duplicate.
|
||||
val markdown =
|
||||
remember(noteEvent, description) {
|
||||
noteEvent.content.ifBlank { null }?.takeUnless { body ->
|
||||
description?.let { normalizeForCompare(body) == normalizeForCompare(it) } == true
|
||||
}
|
||||
}
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
PodcastCoverCard(image, note, accountViewModel)
|
||||
@@ -230,3 +239,8 @@ fun RenderPodcastEpisode(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapses whitespace so two copies of the same text that differ only in wrapping compare equal. */
|
||||
private fun normalizeForCompare(text: String): String = text.trim().replace(WHITESPACE_RUN, " ")
|
||||
|
||||
private val WHITESPACE_RUN = Regex("\\s+")
|
||||
|
||||
@@ -379,6 +379,14 @@ class AccountSessionManager(
|
||||
Log.e("Logoff", "Cannot decode npub for account being logged off; aborting cleanup")
|
||||
return@launch
|
||||
}
|
||||
// TODO: also drop this account's WebView storage profile — the cookies/localStorage of every
|
||||
// site it visited survive here, keyed by NappletWebViewProfiles.forPubKey(hex). It CANNOT be
|
||||
// done from this process: WebView profiles live in the WebView data directory, which belongs
|
||||
// to the `:napplet` process (nothing calls setDataDirectorySuffix, so booting WebView here
|
||||
// too would collide on the same directory). Deleting it needs a broker message that has
|
||||
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
|
||||
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
|
||||
// the sandbox on account deletion.
|
||||
if (accountInfo.npub == currentAccountNPub()) {
|
||||
// Drop the Nest bridge ref before tearing down the
|
||||
// current account so the audio-room activity can't
|
||||
|
||||
+39
-1
@@ -632,6 +632,24 @@ class AccountViewModel(
|
||||
if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set [member]'s CORD-04 roles in [communityId] to exactly [roleIds] (empty revokes
|
||||
* everything). The Control Plane grant REPLACES the member's role set rather than
|
||||
* merging into it, so [roleIds] must be the *complete* list the member should end up
|
||||
* holding — the caller (the Members roster dialog) preselects their current roles for
|
||||
* that reason. Authority is re-checked at fold time by every client, so the caller must
|
||||
* also have offered only roles it strictly outranks on a member it strictly outranks.
|
||||
*/
|
||||
fun setConcordRoles(
|
||||
communityId: String,
|
||||
member: HexKey,
|
||||
roleIds: List<String>,
|
||||
) = launchSigner {
|
||||
if (!account.grantConcordRole(communityId, member, roleIds)) {
|
||||
toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ban/unban [member] from [communityId] (from the Members roster). */
|
||||
fun setConcordBan(
|
||||
communityId: String,
|
||||
@@ -1580,6 +1598,15 @@ class AccountViewModel(
|
||||
|
||||
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
|
||||
|
||||
/**
|
||||
* Drop a Concord community from this account's private kind-13302 list. Fire-and-forget on the
|
||||
* signer dispatcher: the removal lands in the local cache (so the UI updates immediately) and the
|
||||
* new list event is best-effort published to our outbox. Nothing here waits on a relay, which is
|
||||
* what makes leaving a community whose own relays are dead work at all — the list lives in *our*
|
||||
* outbox, not in the community's relays.
|
||||
*/
|
||||
fun leaveConcordCommunity(communityId: String) = launchSigner { account.leaveConcordCommunity(communityId) }
|
||||
|
||||
fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
@@ -2380,7 +2407,18 @@ class AccountViewModel(
|
||||
if (lud16 != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context)
|
||||
val meltResult =
|
||||
MeltProcessor().melt(
|
||||
token,
|
||||
lud16,
|
||||
httpClientBuilder::okHttpClientForMoney,
|
||||
context,
|
||||
// Mints the user deliberately added are exempt from the
|
||||
// private-address block (self-hosted LAN mints are legit).
|
||||
knownWalletMints =
|
||||
account.cashuWalletState.mints.value
|
||||
.toSet(),
|
||||
)
|
||||
onDone(
|
||||
stringRes(context, R.string.cashu_successful_redemption),
|
||||
stringRes(
|
||||
|
||||
+58
-6
@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry
|
||||
@@ -73,6 +74,13 @@ class EmbeddedWebAppController(
|
||||
|
||||
private var sandboxedSdkView: SandboxedSdkView? = null
|
||||
private var pendingAdapter: SandboxedUiAdapter? = null
|
||||
|
||||
/**
|
||||
* True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can
|
||||
* only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the
|
||||
* sandbox destroys its WebView, so the adapter is dead. See [attachView] for why this matters.
|
||||
*/
|
||||
private var adapterDelivered = false
|
||||
private var startUrl: String = "about:blank"
|
||||
|
||||
private var hasLoadedReal = false
|
||||
@@ -92,7 +100,9 @@ class EmbeddedWebAppController(
|
||||
|
||||
// A single NappletBrowserService instance serves every embedded browser tab, so each controller
|
||||
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
|
||||
private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
|
||||
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
|
||||
// previous view can never reap the replacement.
|
||||
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
|
||||
|
||||
/** Invoked on the main thread when the page navigates: (url, canGoBack). */
|
||||
var onUrlChanged: ((String, Boolean) -> Unit)? = null
|
||||
@@ -132,6 +142,7 @@ class EmbeddedWebAppController(
|
||||
serviceMessenger = null
|
||||
sandboxedSdkView = null
|
||||
pendingAdapter = null
|
||||
adapterDelivered = false
|
||||
onUrlChanged = null
|
||||
onImeEvent = null
|
||||
onMagnifierFrame = null
|
||||
@@ -141,15 +152,48 @@ class EmbeddedWebAppController(
|
||||
|
||||
override fun teardown() = unbind()
|
||||
|
||||
/** Hands the surface view to the controller; applies the adapter if it already arrived. */
|
||||
/**
|
||||
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
|
||||
* remote session when this controller is being re-used by a *second* view.
|
||||
*
|
||||
* A warm controller outlives the composition (it lives in the process-scoped [EmbeddedTabHost]), but its
|
||||
* [SandboxedSdkView] does not: an account switch rebuilds the whole logged-in subtree, disposing every
|
||||
* surface. That disposal makes privacysandbox close the remote session, which destroys the sandbox's
|
||||
* WebView — so the adapter this controller already handed out is dead and cannot be given to the fresh
|
||||
* view. A [SandboxedSdkView] with no adapter never builds a ContentView/SurfaceView and paints nothing
|
||||
* but its background colour, forever (the load overlay's retry can't help — it re-navigates a WebView
|
||||
* that no longer exists).
|
||||
*
|
||||
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
|
||||
* session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms this view. The sandbox stamps the
|
||||
* CURRENT account's storage profile on that new session (see [sendCreateSession]), so re-arming can
|
||||
* never resurrect the previous account's cookie jar.
|
||||
*/
|
||||
override fun attachView(view: SandboxedSdkView) {
|
||||
sandboxedSdkView = view
|
||||
// Paint the surface placeholder in the app's theme background so there's no white flash before
|
||||
// the remote WebView delivers its first frame.
|
||||
view.setBackgroundColor(backgroundColor)
|
||||
pendingAdapter?.let {
|
||||
view.setAdapter(it)
|
||||
pendingAdapter = null
|
||||
val adapter = pendingAdapter
|
||||
when {
|
||||
adapter != null -> {
|
||||
pendingAdapter = null
|
||||
adapterDelivered = true
|
||||
view.setAdapter(adapter)
|
||||
}
|
||||
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
|
||||
// behind it is gone, so this view would stay blank forever. Re-create it.
|
||||
adapterDelivered -> {
|
||||
// Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox
|
||||
// asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s
|
||||
// AFTER this create: reusing the id let that late close reap the session we had just asked
|
||||
// for — a new WebView was built, destroyed, and the surface stayed black. A new id makes
|
||||
// the stale close target only the corpse it belongs to.
|
||||
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
|
||||
adapterDelivered = false
|
||||
sendCreateSession()
|
||||
}
|
||||
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +209,9 @@ class EmbeddedWebAppController(
|
||||
putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor)
|
||||
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
|
||||
putString(NappletBrowserContract.KEY_THEME, themeType)
|
||||
// Opaque per-account storage partition, so an embedded site can't carry one
|
||||
// npub's session into another. Derived here (the sandbox never sees the pubkey).
|
||||
putString(NappletBrowserContract.KEY_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
@@ -176,7 +223,12 @@ class EmbeddedWebAppController(
|
||||
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
|
||||
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
|
||||
val view = sandboxedSdkView
|
||||
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
|
||||
if (view != null) {
|
||||
adapterDelivered = true
|
||||
view.setAdapter(adapter)
|
||||
} else {
|
||||
pendingAdapter = adapter
|
||||
}
|
||||
}
|
||||
NappletBrowserContract.MSG_URL_CHANGED -> {
|
||||
val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ private fun EmbeddedWebAppTab(
|
||||
// Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this
|
||||
// re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction).
|
||||
val controller =
|
||||
remember(id, EmbeddedTabHost.themeEpoch) {
|
||||
remember(id, EmbeddedTabHost.rebuildEpoch) {
|
||||
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
|
||||
}
|
||||
|
||||
|
||||
+18
-13
@@ -233,6 +233,24 @@ fun ChatFeedLoaded(
|
||||
}
|
||||
|
||||
Column(modifier = itemModifier) {
|
||||
// A day/subject header belongs ABOVE the message it introduces. `reverseLayout`
|
||||
// flips the order of the lazy list's items, but NOT the content inside one item:
|
||||
// this Column still lays out top-to-bottom, so the divisor must be composed
|
||||
// before the bubble. Composing it after put the header below its own message —
|
||||
// i.e. visually heading the NEXT (newer) message while showing this one's date,
|
||||
// which is why a "Jul 1, 2025" header sat on top of a Sep 23 bubble.
|
||||
NewDateOrSubjectDivisor(older, item)
|
||||
|
||||
// Per-relay paging markers for the gap toward the next-older message. Older items sit
|
||||
// ABOVE newer ones under `reverseLayout`, so that gap is the space above this bubble —
|
||||
// which means these belong before it, for the same reason the divisor does. Composed
|
||||
// after the bubble they rendered in the gap toward the NEWER message, contradicting the
|
||||
// bounds they are handed.
|
||||
markersInGap?.invoke(
|
||||
item.event?.createdAt,
|
||||
older?.event?.createdAt,
|
||||
)
|
||||
|
||||
ChatroomMessageCompose(
|
||||
baseNote = item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
@@ -246,19 +264,6 @@ fun ChatFeedLoaded(
|
||||
groupPosition = watchChatGroupPosition(newer, item, older),
|
||||
previousNoteId = older?.idHex,
|
||||
)
|
||||
|
||||
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
|
||||
|
||||
// Per-relay paging markers belonging in the gap toward the next-older message. With the
|
||||
// reverse layout this draws just above the message (the older side), so a relay's marker
|
||||
// appears right below the oldest message it has reached and slides down as it pages.
|
||||
markersInGap?.invoke(
|
||||
item.event?.createdAt,
|
||||
items.list
|
||||
.getOrNull(index + 1)
|
||||
?.event
|
||||
?.createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-12
@@ -126,6 +126,41 @@ fun ConcordChannelListScreen(
|
||||
var inviteLink by remember { mutableStateOf<String?>(null) }
|
||||
var minting by remember { mutableStateOf(false) }
|
||||
|
||||
// Prefer the folded metadata name, then the stored community name from the list entry (always
|
||||
// present from the join/create — this is what shows everywhere else). Fall back to the app name
|
||||
// only if neither exists (should be unreachable), never as the normal "metadata hasn't folded
|
||||
// yet" placeholder — that showed "Amy Debug".
|
||||
val communityName =
|
||||
state?.metadata?.name
|
||||
?: session?.entry?.name?.ifBlank { null }
|
||||
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
|
||||
|
||||
// Owner from the list entry, not the folded authority: a community whose relays are dead never
|
||||
// folds a Control Plane, and that is exactly the case where leaving matters most.
|
||||
val isOwner = session?.entry?.owner == account.signer.pubKey
|
||||
|
||||
// Read once here (it is @Composable) so the post-leave navigation can use it from a callback.
|
||||
val canPop = nav.canPop()
|
||||
var showLeave by remember { mutableStateOf(false) }
|
||||
|
||||
if (showLeave) {
|
||||
ConcordLeaveDialog(
|
||||
communityName = communityName,
|
||||
isOwner = isOwner,
|
||||
onDismiss = { showLeave = false },
|
||||
onConfirm = {
|
||||
showLeave = false
|
||||
// Fire-and-forget: the list edit is local + a best-effort publish to our own outbox,
|
||||
// so we never hold the user behind a spinner waiting on a relay that may be dead.
|
||||
accountViewModel.leaveConcordCommunity(communityId)
|
||||
// Don't strand the user on the server view of a community they just left. Popping is
|
||||
// right when we were pushed here; when this community is a bottom-nav root there is
|
||||
// nothing to pop, so restart the stack on the Concord hub.
|
||||
if (canPop) nav.popBack() else nav.newStack(Route.Concords)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
|
||||
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
|
||||
val canManageChannels =
|
||||
@@ -185,20 +220,10 @@ fun ConcordChannelListScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
// Prefer the folded metadata name, then the stored community name from the list
|
||||
// entry (always present from the join/create — this is what shows everywhere else).
|
||||
// Fall back to the app name only if neither exists (should be unreachable), never as
|
||||
// the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug".
|
||||
val title =
|
||||
state?.metadata?.name
|
||||
?: session?.entry?.name?.ifBlank { null }
|
||||
?: stringRes(com.vitorpamplona.amethyst.R.string.app_name)
|
||||
Text(title, maxLines = 1)
|
||||
},
|
||||
title = { Text(communityName, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
|
||||
if (nav.canPop()) {
|
||||
if (canPop) {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
|
||||
}
|
||||
@@ -236,6 +261,27 @@ fun ConcordChannelListScreen(
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
}
|
||||
|
||||
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
|
||||
// actions live behind the menu, never as a one-tap icon.
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_community),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
showLeave = true
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -472,6 +518,50 @@ private fun rememberConcordDisplayName(
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms leaving a community. Deliberately explicit about the blast radius: leaving is a private
|
||||
* edit of *this account's* kind-13302 list — nobody is told, no roster changes — but it also drops
|
||||
* the entry that carries the community's keys, so history this account can no longer derive may be
|
||||
* gone for good. The owner gets an extra line: the entry is the only place their owner salt lives,
|
||||
* so leaving is what actually retires the community for them.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordLeaveDialog(
|
||||
communityName: String,
|
||||
isOwner: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_message, communityName))
|
||||
if (isOwner) {
|
||||
Text(
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_leave_owner_warning),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.leave),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A pending channel create ([channelIdHex] null) or rename target. */
|
||||
private data class ConcordChannelEditor(
|
||||
val channelIdHex: String?,
|
||||
|
||||
+113
-13
@@ -23,9 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -38,13 +40,21 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.model.ConcordInviteResult
|
||||
import com.vitorpamplona.amethyst.ui.components.ConcordInvitePreviewRow
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
|
||||
|
||||
private sealed interface RedeemState {
|
||||
/** Showing the local preview, waiting for the user to tap Join. Nothing has been sent. */
|
||||
data object AwaitingConsent : RedeemState
|
||||
|
||||
data object Working : RedeemState
|
||||
|
||||
data class Done(
|
||||
@@ -63,10 +73,25 @@ private sealed interface RedeemState {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
|
||||
* On open it fetches + unlocks the bundle, joins the community, and forwards to its
|
||||
* channel list. On failure it offers a retry, so a transient relay miss doesn't
|
||||
* strand the user.
|
||||
* Redeems a Concord invite link (deep-link target for [Route.ConcordInvite]).
|
||||
*
|
||||
* **This screen must never act before the user consents.** It is reachable from any
|
||||
* `https://amethyst.social/invite/…` link on any web page, in any QR code, or in a
|
||||
* push — i.e. from a URL the user may never have meant to open. Redeeming is a
|
||||
* side-effecting act: it connects to up to three relay URLs *chosen by whoever minted
|
||||
* the link* (disclosing the user's IP to them), publishes a Guestbook JOIN signed by
|
||||
* the user's own identity to those relays, and writes the community into the user's
|
||||
* private kind-13302 list. Doing that on arrival turned any link into a one-click
|
||||
* deanonymize-and-enroll primitive, so the screen now opens on a local-only preview
|
||||
* and only calls [com.vitorpamplona.amethyst.model.Account.joinConcordViaInvite] from
|
||||
* the Join button.
|
||||
*
|
||||
* Everything shown before that tap comes from decoding the URL itself
|
||||
* ([ConcordActions.parseInviteLink] — pure base64 + NIP-19, no I/O): the link's
|
||||
* signer key and the bootstrap relays it would contact. The community's *name* lives
|
||||
* inside the kind-33301 bundle, which only those relays can serve, so it is
|
||||
* deliberately left unknown rather than fetched — fetching it is precisely the IP
|
||||
* disclosure this screen exists to gate.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordInviteScreen(
|
||||
@@ -74,7 +99,19 @@ fun ConcordInviteScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var state by remember(link) { mutableStateOf<RedeemState>(RedeemState.Working) }
|
||||
// Local decode only: base64 fragment + NIP-19 naddr. No relay is contacted here.
|
||||
val parsed = remember(link) { ConcordActions.parseInviteLink(link) }
|
||||
|
||||
var state by
|
||||
remember(link) {
|
||||
mutableStateOf<RedeemState>(
|
||||
if (parsed == null) {
|
||||
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
|
||||
} else {
|
||||
RedeemState.AwaitingConsent
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(link, state) {
|
||||
if (state is RedeemState.Working) {
|
||||
@@ -82,13 +119,15 @@ fun ConcordInviteScreen(
|
||||
when (val result = accountViewModel.account.joinConcordViaInvite(link)) {
|
||||
is ConcordInviteResult.Joined -> RedeemState.Done(result.communityId)
|
||||
is ConcordInviteResult.InvalidLink ->
|
||||
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_invalid, canRetry = false)
|
||||
RedeemState.Failed(R.string.concord_invite_failed_invalid, canRetry = false)
|
||||
is ConcordInviteResult.Incompatible ->
|
||||
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_incompatible, canRetry = false)
|
||||
RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false)
|
||||
is ConcordInviteResult.Revoked ->
|
||||
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed_revoked, canRetry = false)
|
||||
RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false)
|
||||
is ConcordInviteResult.Expired ->
|
||||
RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false)
|
||||
is ConcordInviteResult.NotReachable ->
|
||||
RedeemState.Failed(com.vitorpamplona.amethyst.R.string.concord_invite_failed, canRetry = true)
|
||||
RedeemState.Failed(R.string.concord_invite_failed, canRetry = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,8 +135,8 @@ fun ConcordInviteScreen(
|
||||
LaunchedEffect(state) {
|
||||
(state as? RedeemState.Done)?.let { done ->
|
||||
// Replace this invite screen with the community, dropping it from the back stack. If it
|
||||
// stayed, Back from the community would land on the auto-redeeming spinner, which would
|
||||
// immediately re-join and forward here again — trapping the user in a Back→forward loop.
|
||||
// stayed, Back from the community would land on a consent screen for a community the
|
||||
// user has already joined — a dead end offering to re-do what just happened.
|
||||
nav.popUpTo(Route.ConcordServer(done.communityId), Route.ConcordInvite::class)
|
||||
}
|
||||
}
|
||||
@@ -108,10 +147,19 @@ fun ConcordInviteScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when (state) {
|
||||
is RedeemState.AwaitingConsent ->
|
||||
parsed?.let {
|
||||
ConcordInviteConsent(
|
||||
parsed = it,
|
||||
accountViewModel = accountViewModel,
|
||||
onJoin = { state = RedeemState.Working },
|
||||
)
|
||||
}
|
||||
|
||||
is RedeemState.Working -> {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite),
|
||||
stringRes(R.string.concord_redeeming_invite),
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
@@ -129,7 +177,7 @@ fun ConcordInviteScreen(
|
||||
onClick = { state = RedeemState.Working },
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) {
|
||||
Text(stringRes(com.vitorpamplona.amethyst.R.string.retry))
|
||||
Text(stringRes(R.string.retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,3 +186,55 @@ fun ConcordInviteScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-consent preview. Renders only what the URL itself decodes to — the link
|
||||
* signer (used as the avatar seed) and the bootstrap relays the join would contact —
|
||||
* plus a plain-language statement of what tapping Join will do. It performs **no**
|
||||
* network I/O: the community name would require fetching the bundle from those very
|
||||
* relays, which is the IP disclosure the consent gate exists to prevent, so it shows
|
||||
* an explicit "name unknown until you join" instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordInviteConsent(
|
||||
parsed: ParsedInviteLink,
|
||||
accountViewModel: AccountViewModel,
|
||||
onJoin: () -> Unit,
|
||||
) {
|
||||
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
|
||||
val relayList = remember(parsed) { parsed.fragment.relays.joinToString(", ") }
|
||||
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
ConcordInvitePreviewRow(
|
||||
robotSeed = parsed.linkSignerPubKey,
|
||||
title = stringRes(R.string.concord_invite_card_subtitle),
|
||||
subtitle = stringRes(R.string.concord_invite_preview_unknown_name),
|
||||
accountViewModel = accountViewModel,
|
||||
autoPlayGif = autoPlayGif,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringRes(R.string.concord_invite_preview_explainer),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
)
|
||||
|
||||
if (relayList.isNotEmpty()) {
|
||||
Text(
|
||||
stringRes(R.string.concord_invite_preview_relays, relayList),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onJoin,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
) {
|
||||
Text(stringRes(R.string.concord_invite_card_join))
|
||||
}
|
||||
}
|
||||
|
||||
+157
-7
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -43,6 +46,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -124,12 +128,28 @@ fun ConcordMembersScreen(
|
||||
.minByOrNull { r -> r.position }
|
||||
?.name
|
||||
?.takeIf { n -> n.isNotBlank() }
|
||||
RosterEntry(it, ConcordMembership.of(authority, it), roleName)
|
||||
RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it))
|
||||
}.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey }))
|
||||
}
|
||||
|
||||
val iAmOwner = state?.authority?.isOwner(myPubKey) == true
|
||||
val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true
|
||||
val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true
|
||||
|
||||
// The roles this viewer may actually hand out. The fold drops a grant whose granter does
|
||||
// not *strictly* outrank every assigned role, so offering a role at or above our own
|
||||
// position would publish an edition that every client then silently discards. The owner
|
||||
// sits at rank 0 and no role may claim position 0, so this admits everything for them.
|
||||
val assignableRoles =
|
||||
remember(state, myPubKey) {
|
||||
val authority = state?.authority ?: return@remember emptyList<AssignableRole>()
|
||||
val myRank = authority.rank(myPubKey) ?: return@remember emptyList()
|
||||
authority
|
||||
.roles()
|
||||
.filter { (_, role) -> myRank < role.position }
|
||||
.map { (id, role) -> AssignableRole(id, role.name, role.position) }
|
||||
.sortedBy { it.position }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -168,6 +188,19 @@ fun ConcordMembersScreen(
|
||||
isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true),
|
||||
viewerIsOwner = iAmOwner,
|
||||
viewerCanBan = iCanBan,
|
||||
// Ban/Remove are rank-gated the same way Roles… is. The owner short-circuits
|
||||
// because canActOn begins at hasPermission, which is false while banned, and
|
||||
// a rogue BAN holder can currently banlist the owner — see the note on
|
||||
// Account.concordBanTarget.
|
||||
canBanTarget =
|
||||
iAmOwner ||
|
||||
state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.BAN) == true,
|
||||
viewerCanManageRoles = iCanManageRoles,
|
||||
// canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not
|
||||
// banned, the target isn't the owner (unremovable), and we strictly outrank
|
||||
// them — which also rules out acting on ourselves (equal cannot act on equal).
|
||||
canManageRolesOnTarget = state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.MANAGE_ROLES) == true,
|
||||
assignableRoles = assignableRoles,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -185,6 +218,10 @@ private fun ConcordMemberRow(
|
||||
isSelf: Boolean,
|
||||
viewerIsOwner: Boolean,
|
||||
viewerCanBan: Boolean,
|
||||
canBanTarget: Boolean,
|
||||
viewerCanManageRoles: Boolean,
|
||||
canManageRolesOnTarget: Boolean,
|
||||
assignableRoles: List<AssignableRole>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -193,13 +230,38 @@ private fun ConcordMemberRow(
|
||||
val isBanned = entry.membership == ConcordMembership.BANNED
|
||||
val isAdmin = entry.membership == ConcordMembership.ADMIN
|
||||
|
||||
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders,
|
||||
// never against the owner or yourself. A banned user only offers "unban".
|
||||
// Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders that
|
||||
// strictly outrank the target, never against the owner or yourself. A banned user only offers
|
||||
// "unban" — and unban is rank-gated too, so whoever cannot ban you cannot lift your ban either.
|
||||
val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf
|
||||
val canBan = viewerCanBan && !isOwnerTarget && !isSelf
|
||||
val canBan = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
|
||||
// Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban.
|
||||
val canRemove = viewerCanBan && !isOwnerTarget && !isSelf
|
||||
val hasMenu = canToggleAdmin || canBan || canRemove
|
||||
val canRemove = viewerCanBan && canBanTarget && !isOwnerTarget && !isSelf
|
||||
// Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular
|
||||
// member (or every defined role) is out of our reach — a grant we don't outrank
|
||||
// publishes fine and is then dropped by every client's fold, so a silently no-op
|
||||
// control would be worse than none. The owner's own row never offers it: the owner
|
||||
// is unremovable and outranks everyone, so canManageRolesOnTarget is false there.
|
||||
val rolesBlockedReason =
|
||||
when {
|
||||
!canManageRolesOnTarget -> stringRes(R.string.concord_members_roles_out_of_reach)
|
||||
assignableRoles.isEmpty() -> stringRes(R.string.concord_members_roles_none_assignable)
|
||||
else -> null
|
||||
}
|
||||
val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles
|
||||
|
||||
var editRoles by remember { mutableStateOf(false) }
|
||||
if (editRoles) {
|
||||
ConcordRolesDialog(
|
||||
assignable = assignableRoles,
|
||||
current = entry.roleIds,
|
||||
onConfirm = { selected ->
|
||||
accountViewModel.setConcordRoles(communityId, entry.pubkey, selected)
|
||||
editRoles = false
|
||||
},
|
||||
onDismiss = { editRoles = false },
|
||||
)
|
||||
}
|
||||
|
||||
var confirmRemove by remember { mutableStateOf(false) }
|
||||
if (confirmRemove) {
|
||||
@@ -212,7 +274,7 @@ private fun ConcordMemberRow(
|
||||
)
|
||||
}
|
||||
|
||||
androidx.compose.foundation.layout.Row(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -241,6 +303,27 @@ private fun ConcordMemberRow(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (viewerCanManageRoles) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(stringRes(R.string.concord_members_roles))
|
||||
rolesBlockedReason?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = rolesBlockedReason == null,
|
||||
onClick = {
|
||||
editRoles = true
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canBan) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) },
|
||||
@@ -298,6 +381,64 @@ private fun MemberBadge(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select over the roles the viewer may assign (CORD-04 role grant).
|
||||
*
|
||||
* A grant REPLACES the member's role set rather than merging into it, so the box starts
|
||||
* checked on everything they already hold — otherwise saving would silently strip the
|
||||
* roles that weren't re-checked. Every currently-held role is guaranteed to appear in
|
||||
* [assignable]: the caller only opens this when it strictly outranks the member, and the
|
||||
* member's rank is the *lowest* position they hold, so all of their roles sit strictly
|
||||
* below us too. Like "Make admin", saving applies immediately — no extra confirmation.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordRolesDialog(
|
||||
assignable: List<AssignableRole>,
|
||||
current: Set<String>,
|
||||
onConfirm: (List<String>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selected = remember(current) { mutableStateListOf<String>().apply { addAll(current) } }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.concord_members_roles_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringRes(R.string.concord_members_roles_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
assignable.forEach { role ->
|
||||
val checked = role.id in selected
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (checked) selected.remove(role.id) else selected.add(role.id)
|
||||
}.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(checked = checked, onCheckedChange = null)
|
||||
Text(role.name.ifBlank { role.id.take(8) }, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(selected.toList()) }) {
|
||||
Text(stringRes(R.string.concord_members_roles_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */
|
||||
@Composable
|
||||
private fun ConcordRemoveMemberDialog(
|
||||
@@ -324,6 +465,15 @@ private class RosterEntry(
|
||||
val membership: ConcordMembership,
|
||||
/** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */
|
||||
val roleName: String?,
|
||||
/** Every role id the member currently holds — the preselection for the role picker. */
|
||||
val roleIds: Set<String>,
|
||||
)
|
||||
|
||||
/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */
|
||||
private class AssignableRole(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val position: Long,
|
||||
)
|
||||
|
||||
/** Owner first, then admins, then plain members, then banned last. */
|
||||
|
||||
+13
-24
@@ -22,45 +22,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
|
||||
/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */
|
||||
private val RELAY_GROUP_METADATA_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
GroupPinnedEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* The relay-signed metadata for a NIP-29 group (name/picture/about + admin,
|
||||
* member and role lists), addressed by the group id (`d` tag) and pinned to the
|
||||
* group's host relay. The relay signs these with its own key, so a single-relay
|
||||
* query scoped by `#d` returns exactly this group's directory.
|
||||
*
|
||||
* The 39000-39003 metadata block and the 39005 pin list go out as **two separate filters**: relay29-family
|
||||
* relays (0xchat's included) reject a filter that mixes them and drop the whole REQ, which would leave the
|
||||
* group with no name, no roster and no membership. See
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS].
|
||||
*/
|
||||
fun filterRelayGroupState(
|
||||
channel: RelayGroupChannel,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val relays = channel.relays().toSet()
|
||||
val scope = mapOf("d" to listOf(channel.groupId.id))
|
||||
val directory =
|
||||
relays.map {
|
||||
RelayBasedFilter(
|
||||
relay = it,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_METADATA_KINDS,
|
||||
tags = mapOf("d" to listOf(channel.groupId.id)),
|
||||
since = since?.get(it)?.time,
|
||||
),
|
||||
relays.flatMap {
|
||||
val floor = since?.get(it)?.time
|
||||
listOf(
|
||||
RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)),
|
||||
RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
@@ -101,13 +102,13 @@ fun RelayGroupChannelListScreen(
|
||||
// updates as directory events arrive with no polling. The initial value is sorted too
|
||||
// so the first frame doesn't reshuffle when the first emission arrives.
|
||||
val allChannels by produceState(
|
||||
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() },
|
||||
initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() },
|
||||
relay,
|
||||
) {
|
||||
LocalCache
|
||||
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
|
||||
.collect {
|
||||
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }
|
||||
value = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBySnapshot { it.toBestDisplayName().lowercase() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
@@ -467,8 +468,8 @@ private fun pickCandidates(
|
||||
.asSequence()
|
||||
.filter { it.groupId.id !in forbidden }
|
||||
.filter { it.event != null && isRelaySignedRelayGroup(it, relayInfo) }
|
||||
.sortedBy { it.toBestDisplayName().lowercase() }
|
||||
.toList()
|
||||
.sortedBySnapshot { it.toBestDisplayName().lowercase() }
|
||||
|
||||
/**
|
||||
* The set of group ids reachable as descendants of [rootId] on [relay], following each group's
|
||||
|
||||
+41
-21
@@ -45,16 +45,43 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
* See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan.
|
||||
*/
|
||||
|
||||
/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */
|
||||
val RELAY_GROUP_STATE_KINDS =
|
||||
/**
|
||||
* The relay's **directory** kinds for a group — metadata + admins + members + roles (39000-39003).
|
||||
* These four are what NIP-29 relays treat as a group's "metadata" block, and they must be requested
|
||||
* **alone**: see [RELAY_GROUP_PIN_KINDS].
|
||||
*/
|
||||
val RELAY_GROUP_METADATA_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
GroupPinnedEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* The pin list (39005), deliberately kept in its **own** filter rather than merged into
|
||||
* [RELAY_GROUP_METADATA_KINDS].
|
||||
*
|
||||
* NIP-29 relays derived from `relay29`/`khatru29` (0xchat's `groups.0xchat.com` among them) reject a REQ
|
||||
* whose filter mixes the 39000-39003 metadata kinds with any other kind, replying
|
||||
* `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`. A single filter asking for
|
||||
* 39000-39003 **plus** 39005 is therefore dropped **whole** — the group never resolves its name, roster,
|
||||
* roles or the user's own membership, so it renders as a raw id and offers "Join" to somebody the relay
|
||||
* already lists as an admin.
|
||||
*
|
||||
* Splitting into two filter objects fixes it: those relays evaluate the rule per filter, so the
|
||||
* metadata filter is served normally and the pins filter is served (or harmlessly ignored) on its own.
|
||||
*/
|
||||
val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND)
|
||||
|
||||
/**
|
||||
* Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable
|
||||
* events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and
|
||||
* [RELAY_GROUP_PIN_KINDS] as separate filters instead (see [RELAY_GROUP_PIN_KINDS]). Kept as the
|
||||
* semantic "all state kinds" set for cache/consume-side code.
|
||||
*/
|
||||
val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS
|
||||
|
||||
/** Timeline kinds shown in a group's chat — chat messages and polls. */
|
||||
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
|
||||
|
||||
@@ -69,13 +96,7 @@ val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, Threa
|
||||
* Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each
|
||||
* group's pin list.
|
||||
*/
|
||||
val RELAY_GROUP_DIRECTORY_KINDS =
|
||||
listOf(
|
||||
GroupMetadataEvent.KIND,
|
||||
GroupAdminsEvent.KIND,
|
||||
GroupMembersEvent.KIND,
|
||||
SupportedRolesEvent.KIND,
|
||||
)
|
||||
val RELAY_GROUP_DIRECTORY_KINDS = RELAY_GROUP_METADATA_KINDS
|
||||
|
||||
/** How many directory entries to pull per relay when browsing its whole group list. */
|
||||
const val RELAY_GROUP_DIRECTORY_LIMIT = 500
|
||||
@@ -93,22 +114,21 @@ private fun byHostRelay(joined: Collection<GroupTag>): Map<NormalizedRelayUrl, L
|
||||
}
|
||||
|
||||
/**
|
||||
* State (39000-39005) for every joined group, **one `#d` filter per host relay** carrying that relay's
|
||||
* group ids. `since` is per-relay (replaceable events; a reconnect just re-confirms).
|
||||
* State (39000-39005) for every joined group, **two `#d` filters per host relay** carrying that relay's
|
||||
* group ids: the 39000-39003 metadata block and the 39005 pin list, kept apart because relay29-family
|
||||
* relays refuse a filter that mixes them (see [RELAY_GROUP_PIN_KINDS]). `since` is per-relay (replaceable
|
||||
* events; a reconnect just re-confirms).
|
||||
*/
|
||||
fun buildRelayGroupStateFilters(
|
||||
joined: Collection<GroupTag>,
|
||||
sinceForRelay: (NormalizedRelayUrl) -> Long?,
|
||||
): List<RelayBasedFilter> =
|
||||
byHostRelay(joined).map { (relay, ids) ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = RELAY_GROUP_STATE_KINDS,
|
||||
tags = mapOf(D_TAG to ids.distinct()),
|
||||
since = sinceForRelay(relay),
|
||||
),
|
||||
byHostRelay(joined).flatMap { (relay, ids) ->
|
||||
val scope = mapOf(D_TAG to ids.distinct())
|
||||
val since = sinceForRelay(relay)
|
||||
listOf(
|
||||
RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)),
|
||||
RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+49
-20
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -58,6 +59,8 @@ import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatPreview
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.chatPreviewOf
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -762,26 +765,7 @@ private fun UserRoomCompose(
|
||||
TimeAgo(lastMessage.createdAt())
|
||||
},
|
||||
secondRow = {
|
||||
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
|
||||
if (content != null) {
|
||||
Text(
|
||||
content,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringRes(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
LastMessagePreview(lastMessage, accountViewModel)
|
||||
|
||||
// A sent message I authored counts as read (#1286, #1287); an unsent draft still needs my attention.
|
||||
val newestEvent = lastMessage.event
|
||||
@@ -816,6 +800,51 @@ private fun UserRoomCompose(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line preview of the room's newest message.
|
||||
*
|
||||
* NIP-04 rooms carry ciphertext in `event.content`, so the preview may only ever come from the
|
||||
* decryption cache. [chatPreviewOf] keeps the three not-a-body outcomes apart — still decrypting,
|
||||
* never decryptable, and no event at all — so a message that simply hasn't been opened yet isn't
|
||||
* mislabelled as unreadable. The pending state resolves on its own: [LoadDecryptedContentOrNull]
|
||||
* pushes the plaintext into its state as soon as the signer answers.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowScope.LastMessagePreview(
|
||||
lastMessage: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
LoadDecryptedContentOrNull(lastMessage, accountViewModel) { content ->
|
||||
// Keyed so a scrolling list doesn't re-scan the DM's `p` tags on every recomposition.
|
||||
val preview =
|
||||
remember(lastMessage.event, content) {
|
||||
chatPreviewOf(
|
||||
event = lastMessage.event,
|
||||
decrypted = content,
|
||||
myPubKey = accountViewModel.account.signer.pubKey,
|
||||
canDecrypt = accountViewModel.account.isWriteable(),
|
||||
)
|
||||
}
|
||||
|
||||
val text =
|
||||
when (preview) {
|
||||
is ChatPreview.Body -> preview.text
|
||||
ChatPreview.Decrypting -> stringRes(R.string.chat_preview_decrypting)
|
||||
ChatPreview.Undecryptable -> stringRes(R.string.could_not_decrypt_the_message)
|
||||
ChatPreview.Missing -> stringRes(R.string.referenced_event_not_found)
|
||||
}
|
||||
|
||||
Text(
|
||||
text,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUser(
|
||||
baseUserHex: String,
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
|
||||
/**
|
||||
* Makes every embedded tab (browser / nsite / napplet) follow an account switch.
|
||||
*
|
||||
* Each account gets its own WebView storage jar, and that partition is chosen once, when the WebView is
|
||||
* constructed — so a warm session built for account A can never serve account B. This asks
|
||||
* [EmbeddedTabHost] to tear down and re-warm every session whenever the active account's profile changes;
|
||||
* see [EmbeddedTabHost.rebuildIfProfileChanged] for why reusing them also leaves the tab blank.
|
||||
*
|
||||
* The whole logged-in subtree is recreated per account, so this composable is itself brand new after a
|
||||
* switch — which is exactly why the "which account are the warm sessions built for" marker lives in
|
||||
* [EmbeddedTabHost] (process-scoped) and not in a `remember` here.
|
||||
*
|
||||
* Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader], before them so the stale sessions are
|
||||
* dropped ahead of the first preload sweep. Draws nothing.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@Composable
|
||||
fun EmbeddedTabAccountWatcher() {
|
||||
// Read the same opaque profile name the controllers stamp on their sessions, from the same source, so
|
||||
// the sessions we rebuild are guaranteed to be built against the account we just checked for.
|
||||
val profile = NappletWebViewProfiles.current()
|
||||
|
||||
// SideEffect, not LaunchedEffect: this must land in the SAME frame as the switch. A LaunchedEffect
|
||||
// dispatches through the composition's coroutine scope, and an account switch floods the main thread —
|
||||
// measured 3.0 s and 4.3 s of delay on a real device. For that whole window the tab layer had already
|
||||
// rebuilt every SandboxedSdkView against the surviving (now-dead) controllers, so every embedded tab
|
||||
// was a black rectangle. SideEffect runs at the end of the apply phase, so the stale sessions are torn
|
||||
// down and the epoch bumped before the next composition renders any surface.
|
||||
//
|
||||
// Safe to run on every recomposition: [EmbeddedTabHost.rebuildIfProfileChanged] is idempotent — it
|
||||
// compares against the profile the warm sessions were built for and no-ops when nothing changed.
|
||||
SideEffect { EmbeddedTabHost.rebuildIfProfileChanged(profile) }
|
||||
}
|
||||
+46
-11
@@ -57,12 +57,14 @@ object EmbeddedTabHost {
|
||||
private set
|
||||
|
||||
/**
|
||||
* Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed
|
||||
* WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change
|
||||
* means rebuilding the surface — the favorite screens and the preloader key their acquisition on this
|
||||
* so they re-acquire a freshly-themed session instead of the stale warm one.
|
||||
* Bumped whenever every warm session must be rebuilt from scratch (see [rebuildAll]) — a DARK/LIGHT
|
||||
* theme flip, or an account switch. Both the theme (`nightThemedContext`) and the per-account storage
|
||||
* profile ([com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles]) are locked in at WebView
|
||||
* construction and can't be changed on a live WebView, so following either means building a new one.
|
||||
* The favorite screens and the preloader key their acquisition on this, so they re-acquire a freshly
|
||||
* built session instead of the stale warm one.
|
||||
*/
|
||||
var themeEpoch by mutableStateOf(0)
|
||||
var rebuildEpoch by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
/** Window-space bounds of the active tab's reserved content area. */
|
||||
@@ -169,15 +171,48 @@ object EmbeddedTabHost {
|
||||
}
|
||||
|
||||
/**
|
||||
* The app theme changed: tear down every warm session (their WebViews are pinned to the old theme)
|
||||
* and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions.
|
||||
* Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen
|
||||
* re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface.
|
||||
* Something a WebView can only pick up at construction changed (the theme, or the account): tear down
|
||||
* every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly
|
||||
* built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant
|
||||
* its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface.
|
||||
*/
|
||||
fun rebuildAllForTheme() {
|
||||
fun rebuildAll() {
|
||||
val copy = warm.toList()
|
||||
warm.clear()
|
||||
copy.forEach { it.controller.teardown() }
|
||||
themeEpoch += 1
|
||||
rebuildEpoch += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
|
||||
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
|
||||
* the whole logged-in subtree is rebuilt per account (`key(pubKey)` in `SetAccountCentricViewModelStore`),
|
||||
* so a remembered "last applied" value would be re-seeded to the NEW account on the very first
|
||||
* composition after a switch and the change would never be detected.
|
||||
*/
|
||||
private var builtForProfile: String? = null
|
||||
private var profileSeeded = false
|
||||
|
||||
/**
|
||||
* Rebuilds every warm session when the active account changes, so all embedded apps follow the switch.
|
||||
*
|
||||
* A WebView's storage profile is fixed at construction (`WebViewCompat.setProfile` throws once it has
|
||||
* loaded content), so a live session can't be re-pointed at the new account's jar — it has to be
|
||||
* rebuilt. Rebuilding is also what makes the tab work at all after a switch: the account-keyed subtree
|
||||
* is recreated, which disposes each session's `SandboxedSdkView` and closes the sandbox-side session,
|
||||
* leaving the warm controller holding an already-consumed (and now dead) adapter. Reused as-is, it
|
||||
* would hand the fresh view no adapter and the tab would render permanently blank.
|
||||
*
|
||||
* Covers tabs that aren't on screen too: every warm session is torn down, and the preloader re-warms
|
||||
* the pinned ones against the new profile, so none can come back still bound to the old account.
|
||||
*/
|
||||
fun rebuildIfProfileChanged(profileName: String?) {
|
||||
if (profileSeeded && builtForProfile == profileName) return
|
||||
val isFirstCall = !profileSeeded
|
||||
profileSeeded = true
|
||||
builtForProfile = profileName
|
||||
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
|
||||
// needless bump would restart the preload sweep that is just getting going.
|
||||
if (!isFirstCall) rebuildAll()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -215,13 +215,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
// one's (opaque, pre-first-frame) surface — which itself sits above the nav screens, so the overlay
|
||||
// can't live in the screen. A spinner until a real page paints, or an error+retry when the load
|
||||
// failed or stalled, so a slow / blank / failed load isn't a bare black/white void.
|
||||
//
|
||||
// Gated on the active *id*, not on a live controller: right after [EmbeddedTabHost.rebuildAll] (an
|
||||
// account switch or a theme flip) the active tab has no session at all for a frame or more, and a
|
||||
// SandboxedSdkView with no adapter paints nothing but its background — a black rectangle that reads
|
||||
// as broken. Treat "no session yet" as "still loading" so the spinner covers that window too.
|
||||
val activeController = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller
|
||||
if (activeController != null && bounds.width > 0f && bounds.height > 0f) {
|
||||
var loadStatus by remember(activeId) { mutableStateOf(activeController.loadStatus) }
|
||||
if (activeId != null && bounds.width > 0f && bounds.height > 0f) {
|
||||
var loadStatus by remember(activeId) { mutableStateOf(activeController?.loadStatus ?: EmbeddedLoadStatus()) }
|
||||
var timedOut by remember(activeId) { mutableStateOf(false) }
|
||||
DisposableEffect(activeId, activeController) {
|
||||
activeController.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController.onLoadStatusChanged = null }
|
||||
// No session yet — the tab is mid-rebuild after an account switch, or being warmed for the
|
||||
// first time. Fall back to the "still loading" state so the spinner covers the surface
|
||||
// instead of leaving a bare black rectangle where a dead/absent adapter paints nothing.
|
||||
loadStatus = activeController?.loadStatus ?: EmbeddedLoadStatus()
|
||||
activeController?.onLoadStatusChanged = { loadStatus = it }
|
||||
onDispose { activeController?.onLoadStatusChanged = null }
|
||||
}
|
||||
// Safety net: nothing painted and nothing actively loading after a grace period → offer a retry.
|
||||
LaunchedEffect(activeId, loadStatus) {
|
||||
@@ -241,10 +250,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
).size(bounds.width.toDp(), bounds.height.toDp()),
|
||||
) {
|
||||
EmbeddedLoadOverlay(
|
||||
failed = loadStatus.failed || timedOut,
|
||||
// A tab with no session yet can't have failed — it hasn't started. Keep it on
|
||||
// the spinner so a re-arming tab never flashes an error the user can't act on.
|
||||
failed = activeController != null && (loadStatus.failed || timedOut),
|
||||
onRetry = {
|
||||
timedOut = false
|
||||
activeController.retry()
|
||||
activeController?.retry()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
|
||||
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
|
||||
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
|
||||
private const val PRELOAD_ATTEMPTS = 15
|
||||
private const val PRELOAD_RETRY_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Process-scoped owner of the bottom-bar warm-up sweep, driven by [EmbeddedTabPreloader].
|
||||
*
|
||||
* **Why this isn't just a `LaunchedEffect` in the composable.** After an account switch every warm
|
||||
* session is torn down ([EmbeddedTabHost.rebuildAll]) and must be rebuilt against the new storage
|
||||
* profile — until that happens the tabs have nothing to show. A `LaunchedEffect` dispatches its body
|
||||
* through the composition's coroutine scope, and an account switch floods the main thread: the same
|
||||
* construct made [EmbeddedTabAccountWatcher] fire 3-4 s late before it became a `SideEffect`. Waiting
|
||||
* that long to even *start* the re-warm is what left the tab blank for seconds after a switch.
|
||||
*
|
||||
* So the **kickoff** is synchronous — [request] is called from a `SideEffect`, in the same apply phase
|
||||
* that bumped the epoch — while the sweep itself stays a coroutine, because it genuinely has to suspend
|
||||
* (it awaits the network-registry hydration, then retries pending favorites for ~45 s).
|
||||
* [CoroutineStart.UNDISPATCHED] on [Dispatchers.Main.immediate] means the body runs *inline* up to its
|
||||
* first real suspension, so on a re-warm (registries already hydrated, so `awaitReady` returns without
|
||||
* suspending) the first tab is rebuilt before [request] even returns.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
object EmbeddedTabPreloadSweeper {
|
||||
private data class SweepKey(
|
||||
val favoriteIds: List<String>,
|
||||
val backgroundColor: Int,
|
||||
val rebuildEpoch: Int,
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||
private var job: Job? = null
|
||||
private var lastKey: SweepKey? = null
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the sweep for [favoriteIds]. Idempotent: repeated calls with the same inputs
|
||||
* no-op, so it is safe to call from a `SideEffect` that runs on every recomposition. A changed
|
||||
* [rebuildEpoch] — a theme flip or an account switch — cancels the in-flight sweep and starts a fresh
|
||||
* one, which is what re-warms the tabs the user never opened so none survives bound to the old
|
||||
* account's storage profile.
|
||||
*/
|
||||
fun request(
|
||||
context: Context,
|
||||
favoriteIds: List<String>,
|
||||
backgroundColor: Int,
|
||||
rebuildEpoch: Int,
|
||||
) {
|
||||
val key = SweepKey(favoriteIds, backgroundColor, rebuildEpoch)
|
||||
if (lastKey == key) return
|
||||
lastKey = key
|
||||
job?.cancel()
|
||||
job = null
|
||||
if (favoriteIds.isEmpty()) return
|
||||
// The sweep outlives any single composition, so it must not pin an Activity.
|
||||
val appContext = context.applicationContext
|
||||
job =
|
||||
scope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
sweep(appContext, favoriteIds, backgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.sweep(
|
||||
context: Context,
|
||||
favoriteIds: List<String>,
|
||||
backgroundColor: Int,
|
||||
) {
|
||||
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
|
||||
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
|
||||
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers. On a re-warm these
|
||||
// are already hydrated, so they return without suspending and the first tab is built inline.
|
||||
WebAppNetworkRegistry.init(context)
|
||||
NappletNetworkRegistry.init(context)
|
||||
WebAppNetworkRegistry.awaitReady()
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
|
||||
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
|
||||
// so the sweep doesn't monopolize the frame and jank the paint that follows.
|
||||
yield()
|
||||
}
|
||||
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
|
||||
delay(PRELOAD_RETRY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-41
@@ -24,7 +24,7 @@ import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -33,19 +33,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.yield
|
||||
|
||||
// Up to PRELOAD_ATTEMPTS sweeps, PRELOAD_RETRY_MS apart, give a slow napplet event or a still-connecting
|
||||
// Tor proxy time to settle before we give up and leave that tab to load on first visit (~45 s total).
|
||||
private const val PRELOAD_ATTEMPTS = 15
|
||||
private const val PRELOAD_RETRY_MS = 3_000L
|
||||
|
||||
/**
|
||||
* Warms every bottom-bar favorite at startup so the first tap is instant — the surfaces are built,
|
||||
@@ -54,7 +43,9 @@ private const val PRELOAD_RETRY_MS = 3_000L
|
||||
*
|
||||
* Mount once next to [EmbeddedTabLayer]. Draws nothing; it just drives acquisition. Napplet favorites
|
||||
* whose events haven't synced yet, and Tor-routed sites whose proxy isn't up yet, are retried for a
|
||||
* bounded window (the latter so a clearnet preload can never race ahead of Tor).
|
||||
* bounded window (the latter so a clearnet preload can never race ahead of Tor) — that retry loop, and
|
||||
* everything else that has to suspend, lives in [EmbeddedTabPreloadSweeper]; this composable only kicks
|
||||
* it off, synchronously.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@Composable
|
||||
@@ -66,40 +57,37 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) {
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoriteIds = bottomBarItems.favoriteIds()
|
||||
|
||||
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
|
||||
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
|
||||
// Subscribe to the epoch here (a read inside a SideEffect wouldn't recompose us), so a bump that lands
|
||||
// outside our apply phase — [EmbeddedTabThemeWatcher] bumps it from a LaunchedEffect — still re-runs the
|
||||
// kickoff below. The SideEffect re-reads it, so a bump in the SAME apply phase is picked up immediately.
|
||||
val observedEpoch = EmbeddedTabHost.rebuildEpoch
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
LaunchedEffect(configuration) {
|
||||
|
||||
// Give preloaded surfaces a realistic viewport before any tab is visited, so they download as a
|
||||
// full-size page rather than at the 1dp off-screen fallback. The first real visit corrects it.
|
||||
// Synchronous, and declared BEFORE the kickoff, because the kickoff is synchronous too: as a
|
||||
// LaunchedEffect this would now land *after* the first preload and hand it the off-screen fallback.
|
||||
SideEffect {
|
||||
with(density) {
|
||||
EmbeddedTabHost.seedBoundsIfUnset(Rect(0f, 0f, configuration.screenWidthDp.dp.toPx(), configuration.screenHeightDp.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch,
|
||||
// so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown).
|
||||
LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) {
|
||||
if (favoriteIds.isEmpty()) return@LaunchedEffect
|
||||
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
|
||||
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
|
||||
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers.
|
||||
WebAppNetworkRegistry.init(context)
|
||||
NappletNetworkRegistry.init(context)
|
||||
WebAppNetworkRegistry.awaitReady()
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id }
|
||||
var stillPending = false
|
||||
for (id in favoriteIds) {
|
||||
val app = byId[id] ?: continue
|
||||
if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true
|
||||
// Each preload may build + attach a WebView on this (main) thread; yield between favorites
|
||||
// so the startup sweep doesn't monopolize the frame and jank the first paint.
|
||||
yield()
|
||||
}
|
||||
if (!stillPending || ++attempt >= PRELOAD_ATTEMPTS) break
|
||||
delay(PRELOAD_RETRY_MS)
|
||||
}
|
||||
// Re-warms after a theme flip or an account switch: [rebuildAll] tears down the warm sessions and bumps
|
||||
// the epoch, so this sweep re-acquires them freshly built (keying on the epoch also orders it after the
|
||||
// teardown). This is also what re-warms tabs the user never opened, so none survives bound to the old
|
||||
// account's storage profile.
|
||||
//
|
||||
// SideEffect, not LaunchedEffect: [EmbeddedTabAccountWatcher] tears the sessions down in the apply phase
|
||||
// of the switch, and until this sweep runs there is nothing for the tab to show. A LaunchedEffect
|
||||
// dispatches through the composition's scope, and an account switch floods the main thread — the very
|
||||
// congestion that made the watcher itself fire 3-4 s late. Running here re-arms the tabs in the same
|
||||
// frame that dropped them. The epoch is re-read inside the lambda so we see the watcher's bump (its
|
||||
// SideEffect is ordered before ours), and [EmbeddedTabPreloadSweeper.request] is idempotent, so running
|
||||
// on every recomposition is free.
|
||||
SideEffect {
|
||||
EmbeddedTabPreloadSweeper.request(context, favoriteIds, backgroundColor, maxOf(observedEpoch, EmbeddedTabHost.rebuildEpoch))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ fun EmbeddedTabThemeWatcher() {
|
||||
LaunchedEffect(resolvedDark) {
|
||||
if (applied.value != resolvedDark) {
|
||||
applied.value = resolvedDark
|
||||
EmbeddedTabHost.rebuildAllForTheme()
|
||||
EmbeddedTabHost.rebuildAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
-6
@@ -36,6 +36,7 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
|
||||
@@ -72,9 +73,18 @@ class EmbeddedNostrAppController(
|
||||
private var sandboxedSdkView: SandboxedSdkView? = null
|
||||
private var pendingAdapter: SandboxedUiAdapter? = null
|
||||
|
||||
/**
|
||||
* True once this controller's adapter has actually been handed to a [SandboxedSdkView]. An adapter can
|
||||
* only ever serve ONE view: when that view is disposed, privacysandbox closes the remote session and the
|
||||
* sandbox destroys its WebView, so the adapter is dead. See [attachView].
|
||||
*/
|
||||
private var adapterDelivered = false
|
||||
|
||||
// A single NappletHostService instance serves every embedded napplet tab, so each controller stamps
|
||||
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
|
||||
private val sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
|
||||
// previous view can never reap the replacement.
|
||||
private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
|
||||
// A parked tab can be hidden (paused) before the service even binds, so the pause message is
|
||||
// dropped (no messenger yet). Remember the intent and replay it right after the session is created,
|
||||
@@ -129,6 +139,7 @@ class EmbeddedNostrAppController(
|
||||
serviceMessenger = null
|
||||
sandboxedSdkView = null
|
||||
pendingAdapter = null
|
||||
adapterDelivered = false
|
||||
onStateChanged = null
|
||||
onNotice = null
|
||||
onImeEvent = null
|
||||
@@ -136,14 +147,44 @@ class EmbeddedNostrAppController(
|
||||
onLoadStatusChanged = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the
|
||||
* remote session when this controller is being re-used by a *second* view.
|
||||
*
|
||||
* A warm controller outlives the composition (it lives in the process-scoped
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost]), but its [SandboxedSdkView]
|
||||
* does not: an account switch rebuilds the whole logged-in subtree, disposing every surface. That
|
||||
* disposal makes privacysandbox close the remote session and the sandbox destroy its WebView, so the
|
||||
* adapter already handed out is dead and cannot serve the fresh view. A [SandboxedSdkView] with no
|
||||
* adapter never builds a ContentView/SurfaceView and paints nothing but its background colour, forever.
|
||||
*
|
||||
* So when a new view attaches after the adapter was already delivered, ask the sandbox for a brand new
|
||||
* session; the reply arms this view. [sendCreateSession] re-stamps the CURRENT account's storage
|
||||
* profile, so re-arming can never resurrect the previous account's jar.
|
||||
*/
|
||||
override fun attachView(view: SandboxedSdkView) {
|
||||
sandboxedSdkView = view
|
||||
// Paint the surface placeholder in the app's theme background so there's no white flash before
|
||||
// the remote WebView delivers its first frame.
|
||||
view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE))
|
||||
pendingAdapter?.let {
|
||||
view.setAdapter(it)
|
||||
pendingAdapter = null
|
||||
val adapter = pendingAdapter
|
||||
when {
|
||||
adapter != null -> {
|
||||
pendingAdapter = null
|
||||
adapterDelivered = true
|
||||
view.setAdapter(adapter)
|
||||
}
|
||||
// No adapter in hand and one was already spent on a previous (now disposed) view: the session
|
||||
// behind it is gone, so this view would stay blank forever. Re-create it.
|
||||
adapterDelivered -> {
|
||||
// Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox
|
||||
// asynchronously and can land AFTER this create. Reusing the id would let that late close
|
||||
// reap the session we just asked for, leaving the surface black.
|
||||
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
|
||||
adapterDelivered = false
|
||||
sendCreateSession()
|
||||
}
|
||||
// else: the first session is still in flight; MSG_SESSION_READY will arm this view.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +198,15 @@ class EmbeddedNostrAppController(
|
||||
val msg =
|
||||
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
|
||||
replyTo = incoming
|
||||
data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) }
|
||||
data =
|
||||
Bundle(params).apply {
|
||||
putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
|
||||
// Re-stamp the storage partition at SEND time rather than trusting the one baked
|
||||
// into [params] at construction: a session re-created for a new view (see
|
||||
// [attachView]) must land in the CURRENT account's jar, never the one this
|
||||
// controller was originally built for.
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
runCatching { serviceMessenger?.send(msg) }
|
||||
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
|
||||
@@ -172,7 +221,12 @@ class EmbeddedNostrAppController(
|
||||
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
|
||||
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
|
||||
val view = sandboxedSdkView
|
||||
if (view != null) view.setAdapter(adapter) else pendingAdapter = adapter
|
||||
if (view != null) {
|
||||
adapterDelivered = true
|
||||
view.setAdapter(adapter)
|
||||
} else {
|
||||
pendingAdapter = adapter
|
||||
}
|
||||
}
|
||||
NappletEmbedContract.MSG_STATE -> {
|
||||
val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false
|
||||
|
||||
+2
-2
@@ -114,7 +114,7 @@ private fun EmbeddedNostrAppTab(
|
||||
|
||||
// Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted
|
||||
// on a theme flip (the params carry the resolved theme into the sandbox host's WebView).
|
||||
val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
|
||||
val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) }
|
||||
if (params == null) {
|
||||
UnavailableTab(coordinate, accountViewModel, nav)
|
||||
return
|
||||
@@ -134,7 +134,7 @@ private fun EmbeddedNostrAppTab(
|
||||
val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } }
|
||||
|
||||
val controller =
|
||||
remember(id, EmbeddedTabHost.themeEpoch) {
|
||||
remember(id, EmbeddedTabHost.rebuildEpoch) {
|
||||
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
|
||||
}
|
||||
|
||||
|
||||
+26
-3
@@ -82,6 +82,8 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel
|
||||
import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel
|
||||
import com.vitorpamplona.amethyst.napplet.NappletBrokerService
|
||||
import com.vitorpamplona.amethyst.napplet.counterpartyLabel
|
||||
import com.vitorpamplona.amethyst.napplet.descriptionRes
|
||||
import com.vitorpamplona.amethyst.napplet.labelRes
|
||||
import com.vitorpamplona.amethyst.napplet.resolveNappletMeta
|
||||
@@ -117,7 +119,7 @@ fun ConnectedAppDetailScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
val untitled = stringResource(CommonsR.string.napplet_untitled)
|
||||
|
||||
@@ -219,7 +221,15 @@ fun ConnectedAppDetailScreen(
|
||||
PolicyPicker(
|
||||
selected = current.signerPolicy,
|
||||
onSelect = { newPolicy ->
|
||||
mutate { signerLedger.setPolicy(coordinate, newPolicy) }
|
||||
mutate {
|
||||
signerLedger.setPolicy(coordinate, newPolicy)
|
||||
// Live session grants are consulted BEFORE the policy, so tightening an app
|
||||
// to PARANOID would not have stopped it signing — the grant it already holds
|
||||
// short-circuits the check the new policy would fail. Changing the trust
|
||||
// level is a decision about how this app is treated from now on, so drop
|
||||
// what it is holding and let the new policy actually apply.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -238,7 +248,14 @@ fun ConnectedAppDetailScreen(
|
||||
OpOverrideRow(
|
||||
opKey = opKey,
|
||||
decision = decision,
|
||||
onRevoke = { mutate { signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate) } },
|
||||
onRevoke = {
|
||||
mutate {
|
||||
signerLedger.revokeOpDecision(coordinate, NostrSignerOp.fromKey(opKey) ?: return@mutate)
|
||||
// The persisted override is gone, but a live "allow for this session"
|
||||
// grant would keep authorizing this app until the broker dies.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +311,11 @@ fun ConnectedAppDetailScreen(
|
||||
signerLedger.revokeAll(coordinate)
|
||||
}
|
||||
capabilityLedger.revokeAll(identity)
|
||||
// Forgetting an app has to stop it signing *now*. The two ledgers above only
|
||||
// drop persisted + capability grants; the broker separately holds the signer's
|
||||
// in-memory "allow for this session" grants, which would otherwise keep the
|
||||
// app authorized for as long as any applet surface stays open.
|
||||
NappletBrokerService.revokeSessionGrants(coordinate)
|
||||
}
|
||||
nav.popBack()
|
||||
},
|
||||
@@ -703,6 +725,7 @@ private fun NostrSignerOp.opLabel(): String =
|
||||
is NostrSignerOp.SignKind -> stringResource(R.string.napplet_op_sign_kind, kind)
|
||||
NostrSignerOp.Encrypt -> stringResource(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> stringResource(R.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> stringResource(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ fun ConnectedAppsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val capabilityLedger = remember { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
|
||||
val capabilityLedger = Amethyst.instance.nappletPermissionLedger
|
||||
val signerLedger = remember { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
|
||||
|
||||
var items by remember { mutableStateOf<List<ConnectedAppEntry>?>(null) }
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<string name="channel_image">صورة القناة</string>
|
||||
<string name="referenced_event_not_found">لم يتم العثور على الحدث المشار إليه</string>
|
||||
<string name="could_not_decrypt_the_message">لا يمكن فك تشفير الرسالة</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">صورة المجموعة</string>
|
||||
<string name="explicit_content">محتوى فاضح</string>
|
||||
<string name="relay_notice">إشعار relay</string>
|
||||
@@ -730,6 +731,7 @@
|
||||
<string name="napplet_consent_query">يريد هذا nApplet قراءة الأحداث من relay الخاصة بك.</string>
|
||||
<string name="napplet_consent_storage">يريد هذا nApplet استخدام مساحة التخزين الخاصة به.</string>
|
||||
<string name="napplet_consent_pay">يريد هذا nApplet دفع فاتورة Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">يريد هذا nApplet جلب مورد ويب.</string>
|
||||
<string name="napplet_consent_upload">يريد هذا nApplet تحميل ملف إلى خادم الوسائط الخاص بك.</string>
|
||||
<string name="napplet_consent_notify">يريد هذا nApplet عرض إشعارات لك.</string>
|
||||
@@ -742,11 +744,24 @@
|
||||
<item quantity="many">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
|
||||
<item quantity="other">يريد هذا nApplet دفع فاتورة Lightning بقيمة %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">চ্যানেল ইমেজ</string>
|
||||
<string name="referenced_event_not_found">উল্লেখিত ইভেন্টটি পাওয়া যায় নি</string>
|
||||
<string name="could_not_decrypt_the_message">মেসেজটি ডিক্রিপ্ট করা যায় নি</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">দলগত ছবি</string>
|
||||
<string name="explicit_content">খোলামেলা আধেয়</string>
|
||||
<string name="relay_notice">রিলে নোটিশ</string>
|
||||
@@ -703,6 +704,7 @@
|
||||
<string name="napplet_consent_query">এই nApplet আপনার relay-গুলো থেকে ইভেন্ট পড়তে চায়।</string>
|
||||
<string name="napplet_consent_storage">এই nApplet তার প্রাইভেট স্টোরেজ ব্যবহার করতে চায়।</string>
|
||||
<string name="napplet_consent_pay">এই nApplet একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">এই nApplet একটি ওয়েব রিসোর্স আনতে চায়।</string>
|
||||
<string name="napplet_consent_upload">এই nApplet আপনার মিডিয়া সার্ভারে একটি ফাইল আপলোড করতে চায়।</string>
|
||||
<string name="napplet_consent_notify">এই nApplet আপনাকে বিজ্ঞপ্তি দেখাতে চায়।</string>
|
||||
@@ -711,11 +713,24 @@
|
||||
<item quantity="one">এই nApplet %1$d sat-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
|
||||
<item quantity="other">এই nApplet %1$d sats-এর জন্য একটি Lightning ইনভয়েস পরিশোধ করতে চায়।</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="channel_image">Obrázek kanálu</string>
|
||||
<string name="referenced_event_not_found">Odkazovaná událost nebyla nalezena</string>
|
||||
<string name="could_not_decrypt_the_message">Nepodařilo se dešifrovat zprávu</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Obrázek skupiny</string>
|
||||
<string name="explicit_content">Explicitní obsah</string>
|
||||
<string name="relay_notice">Oznámení relé</string>
|
||||
@@ -832,6 +833,7 @@
|
||||
<string name="napplet_consent_query">Tento nApplet chce číst eventy z vašich relays.</string>
|
||||
<string name="napplet_consent_storage">Tento nApplet chce používat své soukromé úložiště.</string>
|
||||
<string name="napplet_consent_pay">Tento nApplet chce zaplatit Lightning fakturu.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Tento nApplet chce načíst webový zdroj.</string>
|
||||
<string name="napplet_consent_upload">Tento nApplet chce nahrát soubor na váš mediální server.</string>
|
||||
<string name="napplet_consent_notify">Tento nApplet vám chce zobrazovat oznámení.</string>
|
||||
@@ -842,6 +844,15 @@
|
||||
<item quantity="many">Tento nApplet chce zaplatit Lightning fakturu za %1$d sats.</item>
|
||||
<item quantity="other">Tento nApplet chce zaplatit Lightning fakturu za %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Připojit k Nostr</string>
|
||||
<string name="napplet_connect_subtitle">se chce připojit k vašemu účtu Nostr</string>
|
||||
@@ -877,6 +888,10 @@
|
||||
<string name="napplet_op_encrypt">zašifrovat zprávu</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">číst vaše soukromé zprávy</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Připojené aplikace</string>
|
||||
<string name="napplet_permissions_revoke_all">Odvolat všechna oprávnění</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Gruppenbild</string>
|
||||
<string name="explicit_content">Anstößiger Inhalt</string>
|
||||
<string name="relay_notice">Relay-Hinweis</string>
|
||||
@@ -810,6 +811,7 @@
|
||||
<string name="napplet_consent_query">Dieses nApplet möchte Ereignisse von Ihren Relays lesen.</string>
|
||||
<string name="napplet_consent_storage">Dieses nApplet möchte seinen privaten Speicher verwenden.</string>
|
||||
<string name="napplet_consent_pay">Dieses nApplet möchte eine Lightning-Rechnung bezahlen.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Dieses nApplet möchte eine Web-Ressource abrufen.</string>
|
||||
<string name="napplet_consent_upload">Dieses nApplet möchte eine Datei auf Ihren Medienserver hochladen.</string>
|
||||
<string name="napplet_consent_notify">Dieses nApplet möchte Ihnen Benachrichtigungen anzeigen.</string>
|
||||
@@ -818,6 +820,15 @@
|
||||
<item quantity="one">Dieses nApplet möchte eine Lightning-Rechnung über %1$d sat bezahlen.</item>
|
||||
<item quantity="other">Dieses nApplet möchte eine Lightning-Rechnung über %1$d sats bezahlen.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Mit Nostr verbinden</string>
|
||||
<string name="napplet_connect_subtitle">möchte sich mit deinem Nostr-Konto verbinden</string>
|
||||
@@ -853,6 +864,10 @@
|
||||
<string name="napplet_op_encrypt">eine Nachricht verschlüsseln</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">deine privaten Nachrichten lesen</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Verbundene Apps</string>
|
||||
<string name="napplet_permissions_revoke_all">Alle Berechtigungen widerrufen</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Εικόνα Καναλιού</string>
|
||||
<string name="referenced_event_not_found">Η δημοσίευση δεν βρέθηκε</string>
|
||||
<string name="could_not_decrypt_the_message">Αδυναμία αποκρυπτογράφησης μηνύματος</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Εικόνα Ομάδας</string>
|
||||
<string name="explicit_content">Ακριβές Περιεχόμενο</string>
|
||||
<string name="relay_notice">Ειδοποίηση relay</string>
|
||||
@@ -691,6 +692,7 @@
|
||||
<string name="napplet_consent_query">Αυτό το nApplet θέλει να διαβάσει συμβάντα από τα relay σας.</string>
|
||||
<string name="napplet_consent_storage">Αυτό το nApplet θέλει να χρησιμοποιήσει την ιδιωτική του αποθήκευση.</string>
|
||||
<string name="napplet_consent_pay">Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Αυτό το nApplet θέλει να λάβει έναν πόρο από το διαδίκτυο.</string>
|
||||
<string name="napplet_consent_upload">Αυτό το nApplet θέλει να μεταφορτώσει ένα αρχείο στον διακομιστή πολυμέσων σας.</string>
|
||||
<string name="napplet_consent_notify">Αυτό το nApplet θέλει να σας εμφανίσει ειδοποιήσεις.</string>
|
||||
@@ -699,11 +701,24 @@
|
||||
<item quantity="one">Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο αξίας %1$d sat.</item>
|
||||
<item quantity="other">Αυτό το nApplet θέλει να πληρώσει ένα Lightning τιμολόγιο αξίας %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<string name="scan_qr">Scan QR</string>
|
||||
<string name="show_anyway">Show Anyway</string>
|
||||
<string name="post_not_found_short">👀</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
@@ -15,11 +16,25 @@
|
||||
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
|
||||
<!-- Napplet capability names -->
|
||||
<!-- Napplet consent dialog -->
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Kanala Bildo</string>
|
||||
<string name="referenced_event_not_found">Referencita evento netrovita</string>
|
||||
<string name="could_not_decrypt_the_message">Mesaĝo nemalĉifrebla</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Grupa Bildo</string>
|
||||
<string name="explicit_content">Eksplicita Enhavo</string>
|
||||
<string name="relay_notice">Relay-avizo</string>
|
||||
@@ -700,6 +701,7 @@
|
||||
<string name="napplet_consent_query">Ĉi tiu nApplet volas legi eventojn el viaj relay-oj.</string>
|
||||
<string name="napplet_consent_storage">Ĉi tiu nApplet volas uzi sian privatan stokadan lokon.</string>
|
||||
<string name="napplet_consent_pay">Ĉi tiu nApplet volas pagi Lightning-fakturon.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ĉi tiu nApplet volas alporti retan rimedon.</string>
|
||||
<string name="napplet_consent_upload">Ĉi tiu nApplet volas alŝuti dosieron al via amaskomunikilara servilo.</string>
|
||||
<string name="napplet_consent_notify">Ĉi tiu nApplet volas montri al vi sciigojn.</string>
|
||||
@@ -708,11 +710,24 @@
|
||||
<item quantity="one">Ĉi tiu nApplet volas pagi Lightning-fakturon por %1$d sat.</item>
|
||||
<item quantity="other">Ĉi tiu nApplet volas pagi Lightning-fakturon por %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Imagen del canal</string>
|
||||
<string name="referenced_event_not_found">Evento referenciado no encontrado</string>
|
||||
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Imagen de grupo</string>
|
||||
<string name="explicit_content">Contenido explícito</string>
|
||||
<string name="relay_notice">aviso_relé</string>
|
||||
@@ -716,6 +717,7 @@
|
||||
<string name="napplet_consent_query">Este nApplet quiere leer eventos de tus relés.</string>
|
||||
<string name="napplet_consent_storage">Este nApplet quiere utilizar su almacenamiento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quiere pagar una factura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Este nApplet quiere obtener un recurso web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quiere subir un archivo a tu servidor de medios.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quiere mostrarte notificaciones.</string>
|
||||
@@ -724,11 +726,24 @@
|
||||
<item quantity="one">Este nApplet quiere pagar una factura Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Este nApplet quiere pagar una factura Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Imagen del canal</string>
|
||||
<string name="referenced_event_not_found">No se encontró el evento referenciado</string>
|
||||
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Foto de grupo</string>
|
||||
<string name="explicit_content">Contenido explícito</string>
|
||||
<string name="relay_notice">aviso_relé</string>
|
||||
@@ -709,6 +710,7 @@
|
||||
<string name="napplet_consent_query">Este nApplet quiere leer eventos de tus relés.</string>
|
||||
<string name="napplet_consent_storage">Este nApplet quiere utilizar su almacenamiento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quiere pagar una factura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Este nApplet quiere obtener un recurso web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quiere subir un archivo a tu servidor de medios.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quiere mostrarte notificaciones.</string>
|
||||
@@ -717,11 +719,24 @@
|
||||
<item quantity="one">Este nApplet quiere pagar una factura Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Este nApplet quiere pagar una factura Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Imagen del canal</string>
|
||||
<string name="referenced_event_not_found">No se encontró el evento referenciado</string>
|
||||
<string name="could_not_decrypt_the_message">No se pudo desencriptar el mensaje</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Imagen del grupo</string>
|
||||
<string name="explicit_content">Contenido explícito</string>
|
||||
<string name="relay_notice">aviso_relé</string>
|
||||
@@ -709,6 +710,7 @@
|
||||
<string name="napplet_consent_query">Este nApplet quiere leer eventos de tus relés.</string>
|
||||
<string name="napplet_consent_storage">Este nApplet quiere utilizar su almacenamiento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quiere pagar una factura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Este nApplet quiere obtener un recurso web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quiere subir un archivo a tu servidor de medios.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quiere mostrarte notificaciones.</string>
|
||||
@@ -717,11 +719,24 @@
|
||||
<item quantity="one">Este nApplet quiere pagar una factura Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Este nApplet quiere pagar una factura Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">تصویر کانال</string>
|
||||
<string name="referenced_event_not_found">رویداد مورد نظر یافت نشد</string>
|
||||
<string name="could_not_decrypt_the_message">پیام رمزگشایی نشد</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">تصویر گروه</string>
|
||||
<string name="explicit_content">محتوای نامناسب</string>
|
||||
<string name="relay_notice">اطلاعیه relay</string>
|
||||
@@ -702,6 +703,7 @@
|
||||
<string name="napplet_consent_query">این nApplet میخواهد رویدادها را از relayهای شما بخواند.</string>
|
||||
<string name="napplet_consent_storage">این nApplet میخواهد از ذخیرهسازی خصوصی خود استفاده کند.</string>
|
||||
<string name="napplet_consent_pay">این nApplet میخواهد یک فاکتور Lightning پرداخت کند.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">این nApplet میخواهد یک منبع وب دریافت کند.</string>
|
||||
<string name="napplet_consent_upload">این nApplet میخواهد یک فایل به سرور رسانهای شما بارگذاری کند.</string>
|
||||
<string name="napplet_consent_notify">این nApplet میخواهد به شما اعلان نشان دهد.</string>
|
||||
@@ -710,11 +712,24 @@
|
||||
<item quantity="one">این nApplet میخواهد یک فاکتور Lightning به مبلغ %1$d sat پرداخت کند.</item>
|
||||
<item quantity="other">این nApplet میخواهد یک فاکتور Lightning به مبلغ %1$d sat پرداخت کند.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Kanavan kuva</string>
|
||||
<string name="referenced_event_not_found">Viitattua tapahtumaa ei löytynyt</string>
|
||||
<string name="could_not_decrypt_the_message">Viestin purku epäonnistui</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Ryhmäkuva</string>
|
||||
<string name="explicit_content">Julkeaa sisältöä</string>
|
||||
<string name="relay_notice">relay-ilmoitus</string>
|
||||
@@ -693,6 +694,7 @@
|
||||
<string name="napplet_consent_query">Tämä nApplet haluaa lukea tapahtumia relayistasi.</string>
|
||||
<string name="napplet_consent_storage">Tämä nApplet haluaa käyttää yksityistä tallennustaan.</string>
|
||||
<string name="napplet_consent_pay">Tämä nApplet haluaa maksaa Lightning-laskun.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Tämä nApplet haluaa hakea verkkoresurssin.</string>
|
||||
<string name="napplet_consent_upload">Tämä nApplet haluaa ladata tiedoston mediapalvelimellesi.</string>
|
||||
<string name="napplet_consent_notify">Tämä nApplet haluaa näyttää sinulle ilmoituksia.</string>
|
||||
@@ -701,11 +703,24 @@
|
||||
<item quantity="one">Tämä nApplet haluaa maksaa Lightning-laskun, jonka arvo on %1$d sat.</item>
|
||||
<item quantity="other">Tämä nApplet haluaa maksaa Lightning-laskun, jonka arvo on %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Image du canal</string>
|
||||
<string name="referenced_event_not_found">référence de l\'évènement non trouvée</string>
|
||||
<string name="could_not_decrypt_the_message">Impossible de déchiffrer le message</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Image de groupe</string>
|
||||
<string name="explicit_content">Contenu choquant</string>
|
||||
<string name="relay_notice">Annonce du serveur</string>
|
||||
@@ -671,6 +672,7 @@
|
||||
<string name="napplet_consent_query">Ce nApplet veut lire des événements depuis vos relais.</string>
|
||||
<string name="napplet_consent_storage">Ce nApplet veut utiliser son stockage privé.</string>
|
||||
<string name="napplet_consent_pay">Ce nApplet veut payer une facture Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ce nApplet veut récupérer une ressource web.</string>
|
||||
<string name="napplet_consent_upload">Ce nApplet veut téléverser un fichier sur votre serveur multimédia.</string>
|
||||
<string name="napplet_consent_notify">Ce nApplet veut vous afficher des notifications.</string>
|
||||
@@ -679,11 +681,24 @@
|
||||
<item quantity="one">Ce nApplet veut payer une facture Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Ce nApplet veut payer une facture Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Image du canal</string>
|
||||
<string name="referenced_event_not_found">référence de l\'évènement non trouvée</string>
|
||||
<string name="could_not_decrypt_the_message">Impossible de déchiffrer le message</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Image de groupe</string>
|
||||
<string name="explicit_content">Contenu choquant</string>
|
||||
<string name="relay_notice">Annonce du serveur</string>
|
||||
@@ -721,6 +722,7 @@
|
||||
<string name="napplet_consent_query">Ce nApplet veut lire des événements depuis vos relais.</string>
|
||||
<string name="napplet_consent_storage">Ce nApplet veut utiliser son stockage privé.</string>
|
||||
<string name="napplet_consent_pay">Ce nApplet veut payer une facture Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ce nApplet veut récupérer une ressource web.</string>
|
||||
<string name="napplet_consent_upload">Ce nApplet veut téléverser un fichier sur votre serveur multimédia.</string>
|
||||
<string name="napplet_consent_notify">Ce nApplet veut vous afficher des notifications.</string>
|
||||
@@ -729,6 +731,15 @@
|
||||
<item quantity="one">Ce nApplet veut payer une facture Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Ce nApplet veut payer une facture Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Se connecter à Nostr</string>
|
||||
<!-- Signer trust levels -->
|
||||
@@ -760,6 +771,10 @@
|
||||
<string name="napplet_op_encrypt">chiffrer un message</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">lire vos messages privés</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Applications connectées</string>
|
||||
<string name="napplet_permissions_revoke_all">Révoquer toutes les autorisations</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">प्रणाली चित्र</string>
|
||||
<string name="referenced_event_not_found">उद्धृत घटना अप्राप्त</string>
|
||||
<string name="could_not_decrypt_the_message">सन्देश का अरहस्यीकरण असफल</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">समूह चित्र</string>
|
||||
<string name="explicit_content">अभद्र विषयवस्तु</string>
|
||||
<string name="relay_notice">पुनःप्रसारक सूचना</string>
|
||||
@@ -810,6 +811,7 @@
|
||||
<string name="napplet_consent_query">यह नोस्टर संलग्नक्रमक आपके पुनःप्रसारकों से घटनाओं को पढना चाहता है।</string>
|
||||
<string name="napplet_consent_storage">यह नोस्टर संलग्नक्रमक अपना निजी भण्डार का प्रयेग करना चाहता है।</string>
|
||||
<string name="napplet_consent_pay">यह नोस्टर संलग्नक्रमक एक लैटनिंग चालान का भुगतान करना चाहता है।</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">यह नोस्टर संलग्नक्रमक एक जाल संसाधन लाना चाहता है।</string>
|
||||
<string name="napplet_consent_upload">यह नोस्टर संलग्नक्रमक एक अभिलेख को आपके प्रसारसंगणक तक आरोहण करना चाहता है।</string>
|
||||
<string name="napplet_consent_notify">यह नोस्टर संलग्नक्रमक आपको सूचनाएँ दिखाना चाहता है।</string>
|
||||
@@ -818,6 +820,15 @@
|
||||
<item quantity="one">यह नोस्टर संलग्नक्रमक %1$d साट् का एक लैटनिंग चालान का भुगतान करना चाहता है।</item>
|
||||
<item quantity="other">यह नोस्टर संलग्नक्रमक %1$d साट्स का एक लैटनिंग चालान का भुगतान करना चाहता है।</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">नोस्टर से संयोजन</string>
|
||||
<string name="napplet_connect_subtitle">आपके नोस्टर लेखा के साथ संयोजन करना चाहता है</string>
|
||||
@@ -853,6 +864,10 @@
|
||||
<string name="napplet_op_encrypt">सन्देश रहस्यीकरण</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">आपके निजी सन्देशों का पठन</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">संयोजित क्रमक</string>
|
||||
<string name="napplet_permissions_revoke_all">सभी अनुमतियों का निराकरण</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Csatorna profilképe</string>
|
||||
<string name="referenced_event_not_found">A hivatkozott esemény nem található</string>
|
||||
<string name="could_not_decrypt_the_message">Nem sikerült visszafejteni az üzenetet</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Csoport profilképe</string>
|
||||
<string name="explicit_content">Szókimondó tartalom</string>
|
||||
<string name="relay_notice">Átjátszóval kapcsolatos megjegyzések</string>
|
||||
@@ -811,6 +812,7 @@
|
||||
<string name="napplet_consent_query">Ez a nKisalkalmazás eseményeket szeretne olvasni az Ön átjátszóiról.</string>
|
||||
<string name="napplet_consent_storage">Ez a nKisalkalmazás az Ön privát tárhelyét szeretné használni.</string>
|
||||
<string name="napplet_consent_pay">Ez a nKisalkalmazás ki szeretne fizetni egy Lightning számlát.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ez a nKisalkalmazás le szeretne kérni egy webes erőforrást.</string>
|
||||
<string name="napplet_consent_upload">Ez a nKisalkalmazás fel szeretne tölteni egy fájlt az Ön médiakiszolgálójára.</string>
|
||||
<string name="napplet_consent_notify">Ez a nKisalkalmazás értesítéseket szeretne megjeleníteni Önnek.</string>
|
||||
@@ -819,6 +821,15 @@
|
||||
<item quantity="one">Ez a nKisalkalmazás ki szeretne fizetni egy %1$d satoshi értékű Lightning számlát.</item>
|
||||
<item quantity="other">Ez a nKisalkalmazás ki szeretne fizetni egy %1$d satoshi értékű Lightning számlát.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Kapcsolódás a NOSTR-hoz</string>
|
||||
<string name="napplet_connect_subtitle">szeretne csatlakozni a saját Nostr-fiókjához</string>
|
||||
@@ -854,6 +865,10 @@
|
||||
<string name="napplet_op_encrypt">egy üzenet titkosítása</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">saját privát üzenetek olvasása</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Összekapcsolt alkalmazások</string>
|
||||
<string name="napplet_permissions_revoke_all">Minden engedély visszavonása</string>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="channel_image">Gambar Kanal</string>
|
||||
<string name="referenced_event_not_found">Referensi event tidak dapat ditemukan</string>
|
||||
<string name="could_not_decrypt_the_message">Tidak dapat mendekripsi pesan</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Gambar Grup</string>
|
||||
<string name="explicit_content">Konten Eksplisit</string>
|
||||
<string name="relay_notice">Pemberitahuan relay</string>
|
||||
@@ -679,6 +680,7 @@
|
||||
<string name="napplet_consent_query">nApplet ini ingin membaca event dari relay Anda.</string>
|
||||
<string name="napplet_consent_storage">nApplet ini ingin menggunakan penyimpanan pribadinya.</string>
|
||||
<string name="napplet_consent_pay">nApplet ini ingin membayar invoice Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">nApplet ini ingin mengambil sumber daya web.</string>
|
||||
<string name="napplet_consent_upload">nApplet ini ingin mengunggah file ke server media Anda.</string>
|
||||
<string name="napplet_consent_notify">nApplet ini ingin menampilkan notifikasi kepada Anda.</string>
|
||||
@@ -686,11 +688,24 @@
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="other">nApplet ini ingin membayar invoice Lightning sebesar %1$d sat.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Foto del canale</string>
|
||||
<string name="referenced_event_not_found">L\'evento di riferimento non è stato trovato</string>
|
||||
<string name="could_not_decrypt_the_message">Impossibile decriptare il messaggio</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Foto del gruppo</string>
|
||||
<string name="explicit_content">Contenuto esplicito</string>
|
||||
<string name="relay_notice">Avviso relay</string>
|
||||
@@ -683,6 +684,7 @@
|
||||
<string name="napplet_consent_query">Questa nApplet vuole leggere eventi dai tuoi relay.</string>
|
||||
<string name="napplet_consent_storage">Questa nApplet vuole utilizzare la propria archiviazione privata.</string>
|
||||
<string name="napplet_consent_pay">Questa nApplet vuole pagare una fattura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Questa nApplet vuole recuperare una risorsa web.</string>
|
||||
<string name="napplet_consent_upload">Questa nApplet vuole caricare un file sul tuo server multimediale.</string>
|
||||
<string name="napplet_consent_notify">Questa nApplet vuole mostrarti delle notifiche.</string>
|
||||
@@ -691,11 +693,24 @@
|
||||
<item quantity="one">Questa nApplet vuole pagare una fattura Lightning di %1$d sat.</item>
|
||||
<item quantity="other">Questa nApplet vuole pagare una fattura Lightning di %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="channel_image">チャンネル画像</string>
|
||||
<string name="referenced_event_not_found">参照先のイベントが見つかりません</string>
|
||||
<string name="could_not_decrypt_the_message">メッセージが復号できませんでした</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">グループ画像</string>
|
||||
<string name="explicit_content">露骨なコンテンツ</string>
|
||||
<string name="relay_notice">relay 通知</string>
|
||||
@@ -691,6 +692,7 @@
|
||||
<string name="napplet_consent_query">この nApplet はあなたの relay からイベントを読み取ろうとしています。</string>
|
||||
<string name="napplet_consent_storage">この nApplet はプライベートストレージを使用しようとしています。</string>
|
||||
<string name="napplet_consent_pay">この nApplet は Lightning インボイスを支払おうとしています。</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">この nApplet はウェブリソースを取得しようとしています。</string>
|
||||
<string name="napplet_consent_upload">この nApplet はメディアサーバーにファイルをアップロードしようとしています。</string>
|
||||
<string name="napplet_consent_notify">この nApplet はあなたに通知を表示しようとしています。</string>
|
||||
@@ -698,11 +700,24 @@
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="other">この nApplet は %1$d sats の Lightning インボイスを支払おうとしています。</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="channel_image">채널 이미지</string>
|
||||
<string name="referenced_event_not_found">참조된 이벤트를 찾을 수 없습니다</string>
|
||||
<string name="could_not_decrypt_the_message">메시지를 복호화할 수 없습니다</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">그룹 사진</string>
|
||||
<string name="explicit_content">성인 콘텐츠</string>
|
||||
<string name="relay_notice">relay 공지</string>
|
||||
@@ -688,6 +689,7 @@
|
||||
<string name="napplet_consent_query">이 nApplet이 relay에서 이벤트를 읽으려고 합니다.</string>
|
||||
<string name="napplet_consent_storage">이 nApplet이 개인 저장소를 사용하려고 합니다.</string>
|
||||
<string name="napplet_consent_pay">이 nApplet이 Lightning 인보이스를 결제하려고 합니다.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">이 nApplet이 웹 리소스를 가져오려고 합니다.</string>
|
||||
<string name="napplet_consent_upload">이 nApplet이 미디어 서버에 파일을 업로드하려고 합니다.</string>
|
||||
<string name="napplet_consent_notify">이 nApplet이 알림을 표시하려고 합니다.</string>
|
||||
@@ -695,11 +697,24 @@
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="other">이 nApplet이 %1$d sats의 Lightning 인보이스를 결제하려고 합니다.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="channel_image">Kanāla attēls</string>
|
||||
<string name="referenced_event_not_found">Atsaucētais notikums nav atrasts</string>
|
||||
<string name="could_not_decrypt_the_message">Neizdevās atšifrēt ziņojumu</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Grupas attēls</string>
|
||||
<string name="explicit_content">Nepiedienīgs saturs</string>
|
||||
<string name="relay_notice">Relay paziņojums</string>
|
||||
@@ -704,6 +705,7 @@
|
||||
<string name="napplet_consent_query">Šis nApplet vēlas lasīt notikumus no jūsu relays.</string>
|
||||
<string name="napplet_consent_storage">Šis nApplet vēlas izmantot savu privāto krātuvi.</string>
|
||||
<string name="napplet_consent_pay">Šis nApplet vēlas apmaksāt Lightning rēķinu.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Šis nApplet vēlas iegūt tīmekļa resursu.</string>
|
||||
<string name="napplet_consent_upload">Šis nApplet vēlas augšupielādēt failu uz jūsu mediju serveri.</string>
|
||||
<string name="napplet_consent_notify">Šis nApplet vēlas rādīt jums paziņojumus.</string>
|
||||
@@ -713,11 +715,24 @@
|
||||
<item quantity="one">Šī nApplet vēlas apmaksāt Lightning rēķinu par %1$d sat.</item>
|
||||
<item quantity="other">Šī nApplet vēlas apmaksāt Lightning rēķinu par %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Kanaalafbeelding</string>
|
||||
<string name="referenced_event_not_found">Verwezen bericht niet gevonden</string>
|
||||
<string name="could_not_decrypt_the_message">Kon bericht niet ontsleutelen</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Groepsafbeelding</string>
|
||||
<string name="explicit_content">Expliciete inhoud</string>
|
||||
<string name="relay_notice">Relay-bericht</string>
|
||||
@@ -711,6 +712,7 @@
|
||||
<string name="napplet_consent_query">Deze nApplet wil events van je relays lezen.</string>
|
||||
<string name="napplet_consent_storage">Deze nApplet wil gebruikmaken van zijn privéopslag.</string>
|
||||
<string name="napplet_consent_pay">Deze nApplet wil een Lightning-invoice betalen.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Deze nApplet wil een webbron ophalen.</string>
|
||||
<string name="napplet_consent_upload">Deze nApplet wil een bestand uploaden naar je mediaserver.</string>
|
||||
<string name="napplet_consent_notify">Deze nApplet wil je meldingen tonen.</string>
|
||||
@@ -719,6 +721,15 @@
|
||||
<item quantity="one">Deze nApplet wil een Lightning-invoice van %1$d sat betalen.</item>
|
||||
<item quantity="other">Deze nApplet wil een Lightning-invoice van %1$d sats betalen.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Verbinden met Nostr</string>
|
||||
<string name="napplet_connect_subtitle">wil verbinding maken met je Nostr-account</string>
|
||||
@@ -754,6 +765,10 @@
|
||||
<string name="napplet_op_encrypt">een bericht versleutelen</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">je privéberichten lezen</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Verbonden apps</string>
|
||||
<string name="napplet_permissions_revoke_all">Alle machtigingen intrekken</string>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="channel_image">Zdjęcie kanału</string>
|
||||
<string name="referenced_event_not_found">Przywołane zdarzenie nie zostało znalezione</string>
|
||||
<string name="could_not_decrypt_the_message">Nie można odszyfrować wiadomości</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Zdjęcie grupy</string>
|
||||
<string name="explicit_content">Niedozwolona zawartość</string>
|
||||
<string name="relay_notice">Uwagi transmitera</string>
|
||||
@@ -832,6 +833,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="napplet_consent_query">Ten nApplet chce odczytywać wydarzenia z twoich transmiterów.</string>
|
||||
<string name="napplet_consent_storage">Ta aplikacja nApplet chce korzystać ze swojego prywatnego schowka.</string>
|
||||
<string name="napplet_consent_pay">Ta aplikacja nApplet chce zapłacić fakturę w systemie Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ten nApplet chce pobrać zasób internetowy.</string>
|
||||
<string name="napplet_consent_upload">Ten nApplet chce przesłać plik na Twój serwer multimedialny.</string>
|
||||
<string name="napplet_consent_notify">Ten nApplet chce pokazywać Ci powiadomienia.</string>
|
||||
@@ -842,6 +844,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<item quantity="many">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszy.</item>
|
||||
<item quantity="other">Ta aplikacja nApplet chce opłacić fakturę Lightning w wysokości %1$d satoszów.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Połącz z Nostr</string>
|
||||
<string name="napplet_connect_subtitle">chce połączyć się z Twoim kontem Nostr</string>
|
||||
@@ -877,6 +888,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="napplet_op_encrypt">zaszyfruj wiadomość</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">odczytaj prywatne wiadomości</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Podłączone aplikacje</string>
|
||||
<string name="napplet_permissions_revoke_all">Cofnij wszystkie uprawnienia</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Imagem do grupo</string>
|
||||
<string name="explicit_content">Conteúdo explícito</string>
|
||||
<string name="relay_notice">Aviso do relay</string>
|
||||
@@ -808,6 +809,7 @@
|
||||
<string name="napplet_consent_query">Este nApplet quer ler eventos dos seus relays.</string>
|
||||
<string name="napplet_consent_storage">Este nApplet quer usar seu armazenamento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quer pagar uma fatura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Este nApplet quer buscar um recurso da web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quer enviar um arquivo para o seu servidor de mídia.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quer mostrar notificações para você.</string>
|
||||
@@ -816,6 +818,15 @@
|
||||
<item quantity="one">Este nApplet quer pagar uma fatura Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Este nApplet quer pagar uma fatura Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Conectar ao Nostr</string>
|
||||
<string name="napplet_connect_subtitle">quer se conectar à sua conta Nostr</string>
|
||||
@@ -851,6 +862,10 @@
|
||||
<string name="napplet_op_encrypt">criptografar uma mensagem</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">ler suas mensagens privadas</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Apps conectados</string>
|
||||
<string name="napplet_permissions_revoke_all">Revogar todas as permissões</string>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<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>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Imagem do grupo</string>
|
||||
<string name="explicit_content">Conteúdo explícito</string>
|
||||
<string name="relay_notice">Aviso do relay</string>
|
||||
@@ -683,6 +684,7 @@
|
||||
<string name="napplet_consent_query">Este nApplet quer ler eventos dos seus relays.</string>
|
||||
<string name="napplet_consent_storage">Este nApplet quer usar seu armazenamento privado.</string>
|
||||
<string name="napplet_consent_pay">Este nApplet quer pagar uma fatura Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Este nApplet quer buscar um recurso da web.</string>
|
||||
<string name="napplet_consent_upload">Este nApplet quer enviar um arquivo para o seu servidor de mídia.</string>
|
||||
<string name="napplet_consent_notify">Este nApplet quer mostrar notificações para você.</string>
|
||||
@@ -691,11 +693,24 @@
|
||||
<item quantity="one">Este nApplet quer pagar uma fatura Lightning de %1$d sat.</item>
|
||||
<item quantity="other">Este nApplet quer pagar uma fatura Lightning de %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="channel_image">Фото канала</string>
|
||||
<string name="referenced_event_not_found">Связанное событие не найдено</string>
|
||||
<string name="could_not_decrypt_the_message">Не удалось расшифровать сообщение</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Фото группы</string>
|
||||
<string name="explicit_content">Запрещённый контент</string>
|
||||
<string name="relay_notice">Уведомление relay</string>
|
||||
@@ -713,6 +714,7 @@
|
||||
<string name="napplet_consent_query">Этот nApplet хочет читать события с ваших relay.</string>
|
||||
<string name="napplet_consent_storage">Этот nApplet хочет использовать своё приватное хранилище.</string>
|
||||
<string name="napplet_consent_pay">Этот nApplet хочет оплатить Lightning-инвойс.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Этот nApplet хочет загрузить веб-ресурс.</string>
|
||||
<string name="napplet_consent_upload">Этот nApplet хочет загрузить файл на ваш медиасервер.</string>
|
||||
<string name="napplet_consent_notify">Этот nApplet хочет показывать вам уведомления.</string>
|
||||
@@ -723,11 +725,24 @@
|
||||
<item quantity="many">Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats.</item>
|
||||
<item quantity="other">Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="channel_image">Изображение канала</string>
|
||||
<string name="referenced_event_not_found">Указанное событие не найдено</string>
|
||||
<string name="could_not_decrypt_the_message">Не удалось расшифровать сообщение</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Изображение группы</string>
|
||||
<string name="explicit_content">Откровенный контент</string>
|
||||
<string name="relay_notice">Уведомление relay</string>
|
||||
@@ -701,6 +702,7 @@
|
||||
<string name="napplet_consent_query">Этот nApplet хочет читать события с ваших relay.</string>
|
||||
<string name="napplet_consent_storage">Этот nApplet хочет использовать своё приватное хранилище.</string>
|
||||
<string name="napplet_consent_pay">Этот nApplet хочет оплатить Lightning-инвойс.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Этот nApplet хочет загрузить веб-ресурс.</string>
|
||||
<string name="napplet_consent_upload">Этот nApplet хочет загрузить файл на ваш медиасервер.</string>
|
||||
<string name="napplet_consent_notify">Этот nApplet хочет показывать вам уведомления.</string>
|
||||
@@ -711,11 +713,24 @@
|
||||
<item quantity="many">Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats.</item>
|
||||
<item quantity="other">Этот nApplet хочет оплатить Lightning-инвойс на %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="channel_image">Slika kanala</string>
|
||||
<string name="referenced_event_not_found">Referenčni dogodek ni najden</string>
|
||||
<string name="could_not_decrypt_the_message">Dešifriranje sporočila ni uspelo</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Slika skupine</string>
|
||||
<string name="explicit_content">Eksplicitna vsebina</string>
|
||||
<string name="relay_notice">Obvestilo releja</string>
|
||||
@@ -312,6 +313,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="already_have_an_account">Že imam Nostr račun?</string>
|
||||
<string name="loading_feed">Nalagam vir vsebin</string>
|
||||
<string name="loading_account">Račun se nalaga</string>
|
||||
<string name="concord_channels_empty">Še ni kanalov.</string>
|
||||
<string name="concord_send_image_title">Pošlji sliko</string>
|
||||
<string name="concord_open_channel">Odpri kanal</string>
|
||||
<string name="concord_edit_banner_hint">Dodaj pasico</string>
|
||||
@@ -343,9 +345,29 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="concord_edit_save">Shrani</string>
|
||||
<string name="concord_members_title">Člani</string>
|
||||
<string name="concord_members_make_admin">Povišaj v administratorja</string>
|
||||
<string name="concord_members_ban">Blokiraj</string>
|
||||
<string name="concord_members_unban">Odstrani blokado</string>
|
||||
<string name="concord_members_remove">Odstrani iz skupnosti</string>
|
||||
<string name="concord_members_remove_title">Odstranim člana?</string>
|
||||
<string name="concord_members_remove_confirm">Odstrani</string>
|
||||
<string name="concord_role_owner">Lastnik</string>
|
||||
<string name="concord_role_admin">Administrator</string>
|
||||
<string name="concord_role_banned">Blokiran</string>
|
||||
<string name="concord_invite_card_join">Pridruži se skupnosti</string>
|
||||
<string name="concord_invite_card_subtitle">Povabilo Concord skupnosti</string>
|
||||
<string name="concord_server_label">Concord</string>
|
||||
<string name="concord_view_grouped">Po skupnosti</string>
|
||||
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
|
||||
<string name="chat_reply_in_chat">v pogovoru</string>
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<string name="chat_minichat_title">Niz objav</string>
|
||||
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
|
||||
<plurals name="chat_minichat_reply_count">
|
||||
<item quantity="one">%1$d odgovor</item>
|
||||
<item quantity="two">%1$d odgovora</item>
|
||||
<item quantity="few">%1$d odgovori</item>
|
||||
<item quantity="other">%1$d odgovorov</item>
|
||||
</plurals>
|
||||
<string name="chats_history_proto_nip17">šifrirano</string>
|
||||
<string name="chats_history_proto_nip04">starejša različica</string>
|
||||
<string name="chats_reply_searching_history">Iščem originalno sporočilo…</string>
|
||||
@@ -783,6 +805,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="napplet_consent_query">Ta nApplet želi prebrati dogodke z vaših relejev.</string>
|
||||
<string name="napplet_consent_storage">Ta nApplet želi uporabiti svojo zasebno shrambo.</string>
|
||||
<string name="napplet_consent_pay">Ta nApplet želi plačati Lightning račun.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ta nApplet želi pridobiti spletni vir.</string>
|
||||
<string name="napplet_consent_upload">Ta nApplet želi naložiti datoteko na vaš medijski strežnik.</string>
|
||||
<string name="napplet_consent_notify">Ta nApplet vam želi prikazati obvestila.</string>
|
||||
@@ -793,6 +816,15 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<item quantity="few">Ta nApplet želi plačati Lightning račun za %1$d sate.</item>
|
||||
<item quantity="other">Ta nApplet želi plačati Lightning račun za %1$d satov.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Povezava z Nostrom</string>
|
||||
<string name="napplet_connect_subtitle">se želi povezati z vašim nostr računom</string>
|
||||
@@ -828,6 +860,10 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="napplet_op_encrypt">Šifriraj sporočilo</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">berem tvoja zasebna sporočila</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Povezane aplikacije</string>
|
||||
<string name="napplet_permissions_revoke_all">Prekliči vsa dovoljenja</string>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<string name="channel_image">Слика канала</string>
|
||||
<string name="referenced_event_not_found">Референтни догађај није пронађен</string>
|
||||
<string name="could_not_decrypt_the_message">Није могуће дешифровати поруку</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Групна слика</string>
|
||||
<string name="explicit_content">Експлицитни садржај</string>
|
||||
<string name="relay_notice">Obaveštenje relay-a</string>
|
||||
@@ -697,6 +698,7 @@
|
||||
<string name="napplet_consent_query">Ovaj nApplet želi da pročita događaje sa vaših relay-a.</string>
|
||||
<string name="napplet_consent_storage">Ovaj nApplet želi da koristi svoje privatno skladište.</string>
|
||||
<string name="napplet_consent_pay">Ovaj nApplet želi da plati Lightning fakturu.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Ovaj nApplet želi da preuzme veb resurs.</string>
|
||||
<string name="napplet_consent_upload">Ovaj nApplet želi da otpremi datoteku na vaš medija server.</string>
|
||||
<string name="napplet_consent_notify">Ovaj nApplet želi da vam prikazuje obaveštenja.</string>
|
||||
@@ -706,11 +708,24 @@
|
||||
<item quantity="few">Ovaj nApplet želi da plati Lightning fakturu od %1$d sata.</item>
|
||||
<item quantity="other">Ovaj nApplet želi da plati Lightning fakturu od %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Grupp bild</string>
|
||||
<string name="explicit_content">Explicit Innehåll</string>
|
||||
<string name="relay_notice">Relay-meddelande</string>
|
||||
@@ -808,6 +809,7 @@
|
||||
<string name="napplet_consent_query">Det här nApplet vill läsa händelser från dina reläer.</string>
|
||||
<string name="napplet_consent_storage">Det här nApplet vill använda sin privata lagring.</string>
|
||||
<string name="napplet_consent_pay">Det här nApplet vill betala en Lightning-faktura.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Det här nApplet vill hämta en webbresurs.</string>
|
||||
<string name="napplet_consent_upload">Det här nApplet vill ladda upp en fil till din medieserver.</string>
|
||||
<string name="napplet_consent_notify">Det här nApplet vill visa dig aviseringar.</string>
|
||||
@@ -816,6 +818,15 @@
|
||||
<item quantity="one">Det här nApplet vill betala en Lightning-faktura på %1$d sat.</item>
|
||||
<item quantity="other">Det här nApplet vill betala en Lightning-faktura på %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<string name="napplet_connect_title">Anslut till Nostr</string>
|
||||
<string name="napplet_connect_subtitle">vill ansluta till ditt Nostr-konto</string>
|
||||
@@ -851,6 +862,10 @@
|
||||
<string name="napplet_op_encrypt">kryptera ett meddelande</string>
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<string name="napplet_op_decrypt">läsa dina privata meddelanden</string>
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<string name="napplet_permissions_title">Anslutna appar</string>
|
||||
<string name="napplet_permissions_revoke_all">Återkalla alla behörigheter</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Picha ya Kituo</string>
|
||||
<string name="referenced_event_not_found">Tukio linalorejelewa halijapatikana</string>
|
||||
<string name="could_not_decrypt_the_message">Haikuweza kusimbua ujumbe</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Picha ya Kikundi</string>
|
||||
<string name="explicit_content">Maudhui Dhahiri</string>
|
||||
<string name="relay_notice">Ilani ya Usambazaji</string>
|
||||
@@ -693,6 +694,7 @@
|
||||
<string name="napplet_consent_query">nApplet hii inataka kusoma matukio kutoka kwa relay zako.</string>
|
||||
<string name="napplet_consent_storage">nApplet hii inataka kutumia hifadhi yake ya kibinafsi.</string>
|
||||
<string name="napplet_consent_pay">nApplet hii inataka kulipa ankara ya Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">nApplet hii inataka kupata rasilimali ya wavuti.</string>
|
||||
<string name="napplet_consent_upload">nApplet hii inataka kupakia faili kwenye seva yako ya media.</string>
|
||||
<string name="napplet_consent_notify">nApplet hii inataka kukuonyesha arifa.</string>
|
||||
@@ -701,11 +703,24 @@
|
||||
<item quantity="one">nApplet hii inataka kulipa ankara ya Lightning ya sat %1$d.</item>
|
||||
<item quantity="other">nApplet hii inataka kulipa ankara ya Lightning ya sats %1$d.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">சேனல் படம்</string>
|
||||
<string name="referenced_event_not_found">குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை</string>
|
||||
<string name="could_not_decrypt_the_message">செய்தியை மறைகுறியாக்க முடியவில்லை</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">குழுப் படம்</string>
|
||||
<string name="explicit_content">வெளிப்படையான உள்ளடக்கம்</string>
|
||||
<string name="relay_notice">relay அறிவிப்பு</string>
|
||||
@@ -697,6 +698,7 @@
|
||||
<string name="napplet_consent_query">இந்த nApplet உங்கள் relays இலிருந்து நிகழ்வுகளை படிக்க விரும்புகிறது.</string>
|
||||
<string name="napplet_consent_storage">இந்த nApplet அதன் தனிப்பட்ட சேமிப்பை பயன்படுத்த விரும்புகிறது.</string>
|
||||
<string name="napplet_consent_pay">இந்த nApplet ஒரு Lightning இன்வாய்ஸை செலுத்த விரும்புகிறது.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">இந்த nApplet ஒரு இணைய வளத்தை பெற விரும்புகிறது.</string>
|
||||
<string name="napplet_consent_upload">இந்த nApplet உங்கள் மீடியா சேவையகத்தில் ஒரு கோப்பை பதிவேற்ற விரும்புகிறது.</string>
|
||||
<string name="napplet_consent_notify">இந்த nApplet உங்களுக்கு அறிவிப்புகளை காட்ட விரும்புகிறது.</string>
|
||||
@@ -705,11 +707,24 @@
|
||||
<item quantity="one">இந்த nApplet %1$d sat-க்கான Lightning invoice செலுத்த விரும்புகிறது.</item>
|
||||
<item quantity="other">இந்த nApplet %1$d sats-க்கான Lightning invoice செலுத்த விரும்புகிறது.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="channel_image">รูปของ channel</string>
|
||||
<string name="referenced_event_not_found">ไม่พบ event ที่อ้างอิง</string>
|
||||
<string name="could_not_decrypt_the_message">ไม่สามารถเข้ารหัสข้อความได้</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">รูปภาพกลุ่ม</string>
|
||||
<string name="explicit_content">เนื้อหาที่มีความรุนแรง</string>
|
||||
<string name="relay_notice">การแจ้งเตือนจาก relay</string>
|
||||
@@ -681,6 +682,7 @@
|
||||
<string name="napplet_consent_query">nApplet นี้ต้องการอ่านอีเวนต์จาก relay ของคุณ</string>
|
||||
<string name="napplet_consent_storage">nApplet นี้ต้องการใช้พื้นที่จัดเก็บส่วนตัวของตัวเอง</string>
|
||||
<string name="napplet_consent_pay">nApplet นี้ต้องการชำระใบแจ้งหนี้ Lightning</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">nApplet นี้ต้องการดึงข้อมูลทรัพยากรเว็บ</string>
|
||||
<string name="napplet_consent_upload">nApplet นี้ต้องการอัปโหลดไฟล์ไปยังเซิร์ฟเวอร์สื่อของคุณ</string>
|
||||
<string name="napplet_consent_notify">nApplet นี้ต้องการแสดงการแจ้งเตือนให้คุณ</string>
|
||||
@@ -688,11 +690,24 @@
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="other">nApplet นี้ต้องการชำระใบแจ้งหนี้ Lightning เป็นจำนวน %1$d sats</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="channel_image">Kanal Resmi</string>
|
||||
<string name="referenced_event_not_found">Referanslı etkinlik bulunamadı</string>
|
||||
<string name="could_not_decrypt_the_message">Mesaj deşifre edilemedi</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Grup Resmi</string>
|
||||
<string name="explicit_content">Müstehcen İçerik</string>
|
||||
<string name="relay_notice">relay Bildirimi</string>
|
||||
@@ -697,6 +698,7 @@
|
||||
<string name="napplet_consent_query">Bu nApplet relay\'lerinden etkinlikleri okumak istiyor.</string>
|
||||
<string name="napplet_consent_storage">Bu nApplet kendi özel depolamasını kullanmak istiyor.</string>
|
||||
<string name="napplet_consent_pay">Bu nApplet bir Lightning faturası ödemek istiyor.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Bu nApplet bir web kaynağı getirmek istiyor.</string>
|
||||
<string name="napplet_consent_upload">Bu nApplet medya sunucuna dosya yüklemek istiyor.</string>
|
||||
<string name="napplet_consent_notify">Bu nApplet sana bildirim göstermek istiyor.</string>
|
||||
@@ -705,11 +707,24 @@
|
||||
<item quantity="one">Bu nApplet %1$d sat için bir Lightning faturası ödemek istiyor.</item>
|
||||
<item quantity="other">Bu nApplet %1$d sat için bir Lightning faturası ödemek istiyor.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="channel_image">Фото каналу</string>
|
||||
<string name="referenced_event_not_found">Пов\'язану подію не знайдено</string>
|
||||
<string name="could_not_decrypt_the_message">Не вдалося розшифрувати повідомлення</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Фото групи</string>
|
||||
<string name="explicit_content">Відвертий вміст</string>
|
||||
<string name="relay_notice">Повідомлення relay</string>
|
||||
@@ -710,6 +711,7 @@
|
||||
<string name="napplet_consent_query">Цей nApplet хоче читати події з ваших relay.</string>
|
||||
<string name="napplet_consent_storage">Цей nApplet хоче використовувати своє приватне сховище.</string>
|
||||
<string name="napplet_consent_pay">Цей nApplet хоче оплатити Lightning-інвойс.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Цей nApplet хоче отримати веб-ресурс.</string>
|
||||
<string name="napplet_consent_upload">Цей nApplet хоче завантажити файл на ваш медіасервер.</string>
|
||||
<string name="napplet_consent_notify">Цей nApplet хоче показати вам сповіщення.</string>
|
||||
@@ -720,11 +722,24 @@
|
||||
<item quantity="many">Цей nApplet хоче оплатити Lightning-інвойс на %1$d sats.</item>
|
||||
<item quantity="other">Цей nApplet хоче оплатити Lightning-інвойс на %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="channel_image">Kanal rasmi</string>
|
||||
<string name="referenced_event_not_found">Havola qilingan post topilmadi</string>
|
||||
<string name="could_not_decrypt_the_message">Xabarni shifrdan chiqarib bo\'lmadi</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Guruh rasmi</string>
|
||||
<string name="explicit_content">Uyatsiz kontent</string>
|
||||
<string name="relay_notice">Relay bildirishnomasi</string>
|
||||
@@ -700,6 +701,7 @@
|
||||
<string name="napplet_consent_query">Bu nApplet relaylaringizdan voqealarni o\'qishni xohlaydi.</string>
|
||||
<string name="napplet_consent_storage">Bu nApplet o\'zining xususiy saqlash joyidan foydalanishni xohlaydi.</string>
|
||||
<string name="napplet_consent_pay">Bu nApplet Lightning hisob-fakturasini to\'lashni xohlaydi.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">Bu nApplet veb resursini olishni xohlaydi.</string>
|
||||
<string name="napplet_consent_upload">Bu nApplet media serveringizga fayl yuklashni xohlaydi.</string>
|
||||
<string name="napplet_consent_notify">Bu nApplet sizga bildirishnomalar ko\'rsatishni xohlaydi.</string>
|
||||
@@ -708,11 +710,24 @@
|
||||
<item quantity="one">Bu nApplet %1$d sat uchun Lightning hisob-fakturasini to\'lamoqchi.</item>
|
||||
<item quantity="other">Bu nApplet %1$d sats uchun Lightning hisob-fakturasini to\'lamoqchi.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<string name="channel_image">Ảnh kênh</string>
|
||||
<string name="referenced_event_not_found">Không tìm thấy sự kiện được tham chiếu</string>
|
||||
<string name="could_not_decrypt_the_message">Không thể giải mã tin nhắn</string>
|
||||
<!-- Placeholder shown in a chat row while an encrypted message is still being decrypted -->
|
||||
<string name="group_picture">Hình ảnh nhóm</string>
|
||||
<string name="explicit_content">Nội dung người lớn</string>
|
||||
<string name="relay_notice">Thông báo từ relay</string>
|
||||
@@ -680,6 +681,7 @@
|
||||
<string name="napplet_consent_query">nApplet này muốn đọc sự kiện từ các relay của bạn.</string>
|
||||
<string name="napplet_consent_storage">nApplet này muốn sử dụng bộ nhớ riêng tư của nó.</string>
|
||||
<string name="napplet_consent_pay">nApplet này muốn thanh toán một hóa đơn Lightning.</string>
|
||||
<!-- An amountless BOLT11: the payee decides how much. Never render this as "0 sats". -->
|
||||
<string name="napplet_consent_resource">nApplet này muốn tải một tài nguyên web.</string>
|
||||
<string name="napplet_consent_upload">nApplet này muốn tải tệp lên máy chủ phương tiện của bạn.</string>
|
||||
<string name="napplet_consent_notify">nApplet này muốn hiển thị thông báo cho bạn.</string>
|
||||
@@ -687,11 +689,24 @@
|
||||
<plurals name="napplet_consent_pay_amount">
|
||||
<item quantity="other">nApplet này muốn thanh toán hóa đơn Lightning %1$d sats.</item>
|
||||
</plurals>
|
||||
<!-- Consequence lines for event kinds whose payload lives entirely in the tags, so a
|
||||
kind-only summary would hide what is actually being signed. These replaceable lists are
|
||||
already cached on the account, so the dialog diffs the proposed list against the current
|
||||
one and reports what actually changes rather than a raw total. -->
|
||||
<!-- Single-account edits, by far the common case: name who it is instead of counting. %1$s is
|
||||
the display name, shown next to their avatar. -->
|
||||
<!-- %1$s is the joined change list, e.g. "follows 2 new accounts and UNFOLLOWS 130 accounts". -->
|
||||
<!-- Re-publishing an identical list is harmless; say so rather than raising a false alarm. -->
|
||||
<!-- No cached copy to compare against, so the whole list is what gets written. -->
|
||||
<!-- Signer permissions: first-connect dialog -->
|
||||
<!-- Signer trust levels -->
|
||||
<!-- Signer per-op consent dialog -->
|
||||
<!-- Signer op labels -->
|
||||
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
|
||||
<!-- Decrypt scoped to one counterparty. %1$s is that person's name (or a shortened npub) -->
|
||||
<!-- Narrower "remember" choice offered next to "Always allow". %1$s is the counterparty's name -->
|
||||
<!-- Shown as the preview when Amethyst itself cannot decrypt the message the app asked to read -->
|
||||
<!-- Label above the counterparty avatar in the decrypt consent dialog -->
|
||||
<!-- Permissions management screen -->
|
||||
<!-- NIP-46 remote signer (bunker) -->
|
||||
<!-- Relay Authentication (NIP-42) settings -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user