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:
Claude
2026-07-21 00:00:28 +00:00
175 changed files with 9382 additions and 768 deletions
@@ -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))
}
@@ -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
@@ -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
@@ -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)
}
}
@@ -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) + "…"
}
}
@@ -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 {
@@ -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"
@@ -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)
}
@@ -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
@@ -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,
)
}
}
}
}
}
@@ -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?,
)
/**
@@ -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())
}
}
}
@@ -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,
)
}
@@ -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),
)
}
@@ -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,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)
@@ -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)
@@ -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
}
@@ -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() }
}
}
@@ -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") }
}
@@ -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) {
@@ -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) }
}
}
@@ -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)
@@ -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",
@@ -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
@@ -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
@@ -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(
@@ -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()
@@ -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)
}
@@ -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,
)
}
}
}
@@ -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?,
@@ -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))
}
}
@@ -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. */
@@ -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)),
)
}
@@ -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() }
}
}
@@ -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
@@ -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)),
)
}
@@ -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,
@@ -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) }
}
@@ -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()
}
}
@@ -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()
},
)
}
@@ -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)
}
}
}
@@ -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))
}
}
@@ -61,7 +61,7 @@ fun EmbeddedTabThemeWatcher() {
LaunchedEffect(resolvedDark) {
if (applied.value != resolvedDark) {
applied.value = resolvedDark
EmbeddedTabHost.rebuildAllForTheme()
EmbeddedTabHost.rebuildAll()
}
}
}
@@ -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
@@ -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)
}
@@ -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
@@ -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